1use std::{
2 ops::Range,
3 path::{Path, PathBuf},
4 rc::Rc,
5 sync::{
6 Arc,
7 atomic::{AtomicBool, Ordering},
8 },
9 time::Duration,
10};
11
12use acp_thread::{AcpThread, MentionUri, ThreadStatus};
13use agent::{ContextServerRegistry, SharedThread, ThreadStore};
14use agent_client_protocol as acp;
15use agent_servers::AgentServer;
16use collections::HashSet;
17use db::kvp::{Dismissable, KeyValueStore};
18use itertools::Itertools;
19use project::AgentId;
20use serde::{Deserialize, Serialize};
21use settings::{LanguageModelProviderSetting, LanguageModelSelection};
22
23use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
24use zed_actions::agent::{
25 ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent,
26 ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
27};
28
29use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault};
30use crate::{
31 AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
32 Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread,
33 OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
34 StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
35 agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
36 conversation_view::{AcpThreadViewEvent, ThreadView},
37 slash_command::SlashCommandCompletionProvider,
38 text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate},
39 ui::EndTrialUpsell,
40};
41use crate::{
42 Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
43 NewNativeAgentThreadFromSummary,
44};
45use crate::{
46 ExpandMessageEditor, ThreadHistoryView,
47 text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
48};
49use crate::{ManageProfiles, ThreadHistoryViewEvent};
50use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
51use agent_settings::AgentSettings;
52use ai_onboarding::AgentPanelOnboarding;
53use anyhow::{Context as _, Result, anyhow};
54use assistant_slash_command::SlashCommandWorkingSet;
55use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
56use client::UserStore;
57use cloud_api_types::Plan;
58use collections::HashMap;
59use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
60use extension::ExtensionEvents;
61use extension_host::ExtensionStore;
62use fs::Fs;
63use gpui::{
64 Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
65 DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
66 Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
67};
68use language::LanguageRegistry;
69use language_model::{ConfigurationError, LanguageModelRegistry};
70use project::project_settings::ProjectSettings;
71use project::{Project, ProjectPath, Worktree};
72use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
73use rules_library::{RulesLibrary, open_rules_library};
74use search::{BufferSearchBar, buffer_search};
75use settings::{Settings, update_settings_file};
76use theme::ThemeSettings;
77use ui::{
78 Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide,
79 KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
80};
81use util::{ResultExt as _, debug_panic};
82use workspace::{
83 CollaboratorId, DraggedSelection, DraggedTab, OpenResult, PathList, SerializedPathList,
84 ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
85 dock::{DockPosition, Panel, PanelEvent},
86};
87use zed_actions::{
88 DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
89 agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
90 assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
91};
92
93const AGENT_PANEL_KEY: &str = "agent_panel";
94const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
95const DEFAULT_THREAD_TITLE: &str = "New Thread";
96
97fn read_serialized_panel(
98 workspace_id: workspace::WorkspaceId,
99 kvp: &KeyValueStore,
100) -> Option<SerializedAgentPanel> {
101 let scope = kvp.scoped(AGENT_PANEL_KEY);
102 let key = i64::from(workspace_id).to_string();
103 scope
104 .read(&key)
105 .log_err()
106 .flatten()
107 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
108}
109
110async fn save_serialized_panel(
111 workspace_id: workspace::WorkspaceId,
112 panel: SerializedAgentPanel,
113 kvp: KeyValueStore,
114) -> Result<()> {
115 let scope = kvp.scoped(AGENT_PANEL_KEY);
116 let key = i64::from(workspace_id).to_string();
117 scope.write(key, serde_json::to_string(&panel)?).await?;
118 Ok(())
119}
120
121/// Migration: reads the original single-panel format stored under the
122/// `"agent_panel"` KVP key before per-workspace keying was introduced.
123fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPanel> {
124 kvp.read_kvp(AGENT_PANEL_KEY)
125 .log_err()
126 .flatten()
127 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
128}
129
130#[derive(Serialize, Deserialize, Debug)]
131struct SerializedAgentPanel {
132 width: Option<Pixels>,
133 selected_agent: Option<AgentType>,
134 #[serde(default)]
135 last_active_thread: Option<SerializedActiveThread>,
136 #[serde(default)]
137 start_thread_in: Option<StartThreadIn>,
138}
139
140#[derive(Serialize, Deserialize, Debug)]
141struct SerializedActiveThread {
142 session_id: String,
143 agent_type: AgentType,
144 title: Option<String>,
145 work_dirs: Option<SerializedPathList>,
146}
147
148pub fn init(cx: &mut App) {
149 cx.observe_new(
150 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
151 workspace
152 .register_action(|workspace, action: &NewThread, window, cx| {
153 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
154 panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
155 workspace.focus_panel::<AgentPanel>(window, cx);
156 }
157 })
158 .register_action(
159 |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
160 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
161 panel.update(cx, |panel, cx| {
162 panel.new_native_agent_thread_from_summary(action, window, cx)
163 });
164 workspace.focus_panel::<AgentPanel>(window, cx);
165 }
166 },
167 )
168 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
169 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
170 workspace.focus_panel::<AgentPanel>(window, cx);
171 panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
172 }
173 })
174 .register_action(|workspace, _: &OpenHistory, window, cx| {
175 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
176 workspace.focus_panel::<AgentPanel>(window, cx);
177 panel.update(cx, |panel, cx| panel.open_history(window, cx));
178 }
179 })
180 .register_action(|workspace, _: &OpenSettings, window, cx| {
181 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
182 workspace.focus_panel::<AgentPanel>(window, cx);
183 panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
184 }
185 })
186 .register_action(|workspace, _: &NewTextThread, window, cx| {
187 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
188 workspace.focus_panel::<AgentPanel>(window, cx);
189 panel.update(cx, |panel, cx| {
190 panel.new_text_thread(window, cx);
191 });
192 }
193 })
194 .register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
195 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
196 workspace.focus_panel::<AgentPanel>(window, cx);
197 panel.update(cx, |panel, cx| {
198 panel.external_thread(
199 action.agent.clone(),
200 None,
201 None,
202 None,
203 None,
204 true,
205 window,
206 cx,
207 )
208 });
209 }
210 })
211 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
212 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
213 workspace.focus_panel::<AgentPanel>(window, cx);
214 panel.update(cx, |panel, cx| {
215 panel.deploy_rules_library(action, window, cx)
216 });
217 }
218 })
219 .register_action(|workspace, _: &Follow, window, cx| {
220 workspace.follow(CollaboratorId::Agent, window, cx);
221 })
222 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
223 let thread = workspace
224 .panel::<AgentPanel>(cx)
225 .and_then(|panel| panel.read(cx).active_conversation_view().cloned())
226 .and_then(|conversation| {
227 conversation
228 .read(cx)
229 .active_thread()
230 .map(|r| r.read(cx).thread.clone())
231 });
232
233 if let Some(thread) = thread {
234 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
235 }
236 })
237 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
238 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
239 workspace.focus_panel::<AgentPanel>(window, cx);
240 panel.update(cx, |panel, cx| {
241 panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
242 });
243 }
244 })
245 .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
246 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
247 workspace.focus_panel::<AgentPanel>(window, cx);
248 panel.update(cx, |panel, cx| {
249 panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
250 });
251 }
252 })
253 .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
254 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
255 workspace.focus_panel::<AgentPanel>(window, cx);
256 panel.update(cx, |panel, cx| {
257 panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
258 });
259 }
260 })
261 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
262 AcpOnboardingModal::toggle(workspace, window, cx)
263 })
264 .register_action(
265 |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| {
266 ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
267 },
268 )
269 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
270 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
271 window.refresh();
272 })
273 .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
274 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
275 panel.update(cx, |panel, _| {
276 panel
277 .on_boarding_upsell_dismissed
278 .store(false, Ordering::Release);
279 });
280 }
281 OnboardingUpsell::set_dismissed(false, cx);
282 })
283 .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
284 TrialEndUpsell::set_dismissed(false, cx);
285 })
286 .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
287 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
288 panel.update(cx, |panel, cx| {
289 panel.reset_agent_zoom(window, cx);
290 });
291 }
292 })
293 .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
294 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
295 panel.update(cx, |panel, cx| {
296 panel.copy_thread_to_clipboard(window, cx);
297 });
298 }
299 })
300 .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
301 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
302 workspace.focus_panel::<AgentPanel>(window, cx);
303 panel.update(cx, |panel, cx| {
304 panel.load_thread_from_clipboard(window, cx);
305 });
306 }
307 })
308 .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
309 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
310 return;
311 };
312
313 let mention_uri = MentionUri::GitDiff {
314 base_ref: action.base_ref.to_string(),
315 };
316 let diff_uri = mention_uri.to_uri().to_string();
317
318 let content_blocks = vec![
319 acp::ContentBlock::Text(acp::TextContent::new(
320 "Please review this branch diff carefully. Point out any issues, \
321 potential bugs, or improvement opportunities you find.\n\n"
322 .to_string(),
323 )),
324 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
325 acp::EmbeddedResourceResource::TextResourceContents(
326 acp::TextResourceContents::new(
327 action.diff_text.to_string(),
328 diff_uri,
329 ),
330 ),
331 )),
332 ];
333
334 workspace.focus_panel::<AgentPanel>(window, cx);
335
336 panel.update(cx, |panel, cx| {
337 panel.external_thread(
338 None,
339 None,
340 None,
341 None,
342 Some(AgentInitialContent::ContentBlock {
343 blocks: content_blocks,
344 auto_submit: true,
345 }),
346 true,
347 window,
348 cx,
349 );
350 });
351 })
352 .register_action(
353 |workspace, action: &ResolveConflictsWithAgent, window, cx| {
354 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
355 return;
356 };
357
358 let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
359
360 workspace.focus_panel::<AgentPanel>(window, cx);
361
362 panel.update(cx, |panel, cx| {
363 panel.external_thread(
364 None,
365 None,
366 None,
367 None,
368 Some(AgentInitialContent::ContentBlock {
369 blocks: content_blocks,
370 auto_submit: true,
371 }),
372 true,
373 window,
374 cx,
375 );
376 });
377 },
378 )
379 .register_action(
380 |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
381 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
382 return;
383 };
384
385 let content_blocks =
386 build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
387
388 workspace.focus_panel::<AgentPanel>(window, cx);
389
390 panel.update(cx, |panel, cx| {
391 panel.external_thread(
392 None,
393 None,
394 None,
395 None,
396 Some(AgentInitialContent::ContentBlock {
397 blocks: content_blocks,
398 auto_submit: true,
399 }),
400 true,
401 window,
402 cx,
403 );
404 });
405 },
406 )
407 .register_action(|workspace, action: &StartThreadIn, window, cx| {
408 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
409 panel.update(cx, |panel, cx| {
410 panel.set_start_thread_in(action, window, cx);
411 });
412 }
413 })
414 .register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
415 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
416 panel.update(cx, |panel, cx| {
417 panel.cycle_start_thread_in(window, cx);
418 });
419 }
420 });
421 },
422 )
423 .detach();
424}
425
426fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
427 let mention_uri = MentionUri::MergeConflict {
428 file_path: conflict.file_path.clone(),
429 };
430 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
431 acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
432 conflict.conflict_text.clone(),
433 mention_uri.to_uri().to_string(),
434 )),
435 ))
436}
437
438fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
439 if conflicts.is_empty() {
440 return Vec::new();
441 }
442
443 let mut blocks = Vec::new();
444
445 if conflicts.len() == 1 {
446 let conflict = &conflicts[0];
447
448 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
449 "Please resolve the following merge conflict in ",
450 )));
451 let mention = MentionUri::File {
452 abs_path: PathBuf::from(conflict.file_path.clone()),
453 };
454 blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
455 mention.name(),
456 mention.to_uri(),
457 )));
458
459 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
460 indoc::formatdoc!(
461 "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
462
463 Analyze both versions carefully and resolve the conflict by editing \
464 the file directly. Choose the resolution that best preserves the intent \
465 of both changes, or combine them if appropriate.
466
467 ",
468 ours = conflict.ours_branch_name,
469 theirs = conflict.theirs_branch_name,
470 ),
471 )));
472 } else {
473 let n = conflicts.len();
474 let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
475 let ours = &conflicts[0].ours_branch_name;
476 let theirs = &conflicts[0].theirs_branch_name;
477 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
478 indoc::formatdoc!(
479 "Please resolve all {n} merge conflicts below.
480
481 The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
482
483 For each conflict, analyze both versions carefully and resolve them \
484 by editing the file{suffix} directly. Choose resolutions that best preserve \
485 the intent of both changes, or combine them if appropriate.
486
487 ",
488 suffix = if unique_files.len() > 1 { "s" } else { "" },
489 ),
490 )));
491 }
492
493 for conflict in conflicts {
494 blocks.push(conflict_resource_block(conflict));
495 }
496
497 blocks
498}
499
500fn build_conflicted_files_resolution_prompt(
501 conflicted_file_paths: &[String],
502) -> Vec<acp::ContentBlock> {
503 if conflicted_file_paths.is_empty() {
504 return Vec::new();
505 }
506
507 let instruction = indoc::indoc!(
508 "The following files have unresolved merge conflicts. Please open each \
509 file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
510 and resolve every conflict by editing the files directly.
511
512 Choose resolutions that best preserve the intent of both changes, \
513 or combine them if appropriate.
514
515 Files with conflicts:
516 ",
517 );
518
519 let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
520 for path in conflicted_file_paths {
521 let mention = MentionUri::File {
522 abs_path: PathBuf::from(path),
523 };
524 content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
525 mention.name(),
526 mention.to_uri(),
527 )));
528 content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
529 }
530 content
531}
532
533#[derive(Clone, Debug, PartialEq, Eq)]
534enum History {
535 AgentThreads { view: Entity<ThreadHistoryView> },
536 TextThreads,
537}
538
539enum ActiveView {
540 Uninitialized,
541 AgentThread {
542 conversation_view: Entity<ConversationView>,
543 },
544 TextThread {
545 text_thread_editor: Entity<TextThreadEditor>,
546 title_editor: Entity<Editor>,
547 buffer_search_bar: Entity<BufferSearchBar>,
548 _subscriptions: Vec<gpui::Subscription>,
549 },
550 History {
551 history: History,
552 },
553 Configuration,
554}
555
556enum WhichFontSize {
557 AgentFont,
558 BufferFont,
559 None,
560}
561
562// TODO unify this with ExternalAgent
563#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
564pub enum AgentType {
565 #[default]
566 NativeAgent,
567 TextThread,
568 Custom {
569 #[serde(rename = "name")]
570 id: AgentId,
571 },
572}
573
574impl AgentType {
575 pub fn is_native(&self) -> bool {
576 matches!(self, Self::NativeAgent)
577 }
578
579 fn label(&self) -> SharedString {
580 match self {
581 Self::NativeAgent | Self::TextThread => "Zed Agent".into(),
582 Self::Custom { id, .. } => id.0.clone(),
583 }
584 }
585
586 fn icon(&self) -> Option<IconName> {
587 match self {
588 Self::NativeAgent | Self::TextThread => None,
589 Self::Custom { .. } => Some(IconName::Sparkle),
590 }
591 }
592}
593
594impl From<Agent> for AgentType {
595 fn from(value: Agent) -> Self {
596 match value {
597 Agent::Custom { id } => Self::Custom { id },
598 Agent::NativeAgent => Self::NativeAgent,
599 }
600 }
601}
602
603impl StartThreadIn {
604 fn label(&self) -> SharedString {
605 match self {
606 Self::LocalProject => "Current Worktree".into(),
607 Self::NewWorktree => "New Git Worktree".into(),
608 }
609 }
610}
611
612#[derive(Clone, Debug)]
613#[allow(dead_code)]
614pub enum WorktreeCreationStatus {
615 Creating,
616 Error(SharedString),
617}
618
619impl ActiveView {
620 pub fn which_font_size_used(&self) -> WhichFontSize {
621 match self {
622 ActiveView::Uninitialized
623 | ActiveView::AgentThread { .. }
624 | ActiveView::History { .. } => WhichFontSize::AgentFont,
625 ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
626 ActiveView::Configuration => WhichFontSize::None,
627 }
628 }
629
630 pub fn text_thread(
631 text_thread_editor: Entity<TextThreadEditor>,
632 language_registry: Arc<LanguageRegistry>,
633 window: &mut Window,
634 cx: &mut App,
635 ) -> Self {
636 let title = text_thread_editor.read(cx).title(cx).to_string();
637
638 let editor = cx.new(|cx| {
639 let mut editor = Editor::single_line(window, cx);
640 editor.set_text(title, window, cx);
641 editor
642 });
643
644 // This is a workaround for `editor.set_text` emitting a `BufferEdited` event, which would
645 // cause a custom summary to be set. The presence of this custom summary would cause
646 // summarization to not happen.
647 let mut suppress_first_edit = true;
648
649 let subscriptions = vec![
650 window.subscribe(&editor, cx, {
651 {
652 let text_thread_editor = text_thread_editor.clone();
653 move |editor, event, window, cx| match event {
654 EditorEvent::BufferEdited => {
655 if suppress_first_edit {
656 suppress_first_edit = false;
657 return;
658 }
659 let new_summary = editor.read(cx).text(cx);
660
661 text_thread_editor.update(cx, |text_thread_editor, cx| {
662 text_thread_editor
663 .text_thread()
664 .update(cx, |text_thread, cx| {
665 text_thread.set_custom_summary(new_summary, cx);
666 })
667 })
668 }
669 EditorEvent::Blurred => {
670 if editor.read(cx).text(cx).is_empty() {
671 let summary = text_thread_editor
672 .read(cx)
673 .text_thread()
674 .read(cx)
675 .summary()
676 .or_default();
677
678 editor.update(cx, |editor, cx| {
679 editor.set_text(summary, window, cx);
680 });
681 }
682 }
683 _ => {}
684 }
685 }
686 }),
687 window.subscribe(&text_thread_editor.read(cx).text_thread().clone(), cx, {
688 let editor = editor.clone();
689 move |text_thread, event, window, cx| match event {
690 TextThreadEvent::SummaryGenerated => {
691 let summary = text_thread.read(cx).summary().or_default();
692
693 editor.update(cx, |editor, cx| {
694 editor.set_text(summary, window, cx);
695 })
696 }
697 TextThreadEvent::PathChanged { .. } => {}
698 _ => {}
699 }
700 }),
701 ];
702
703 let buffer_search_bar =
704 cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx));
705 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
706 buffer_search_bar.set_active_pane_item(Some(&text_thread_editor), window, cx)
707 });
708
709 Self::TextThread {
710 text_thread_editor,
711 title_editor: editor,
712 buffer_search_bar,
713 _subscriptions: subscriptions,
714 }
715 }
716}
717
718pub struct AgentPanel {
719 workspace: WeakEntity<Workspace>,
720 /// Workspace id is used as a database key
721 workspace_id: Option<WorkspaceId>,
722 user_store: Entity<UserStore>,
723 project: Entity<Project>,
724 fs: Arc<dyn Fs>,
725 language_registry: Arc<LanguageRegistry>,
726 text_thread_history: Entity<TextThreadHistory>,
727 thread_store: Entity<ThreadStore>,
728 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
729 prompt_store: Option<Entity<PromptStore>>,
730 connection_store: Entity<AgentConnectionStore>,
731 context_server_registry: Entity<ContextServerRegistry>,
732 configuration: Option<Entity<AgentConfiguration>>,
733 configuration_subscription: Option<Subscription>,
734 focus_handle: FocusHandle,
735 active_view: ActiveView,
736 previous_view: Option<ActiveView>,
737 background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
738 new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
739 start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
740 agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
741 agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
742 agent_navigation_menu: Option<Entity<ContextMenu>>,
743 _extension_subscription: Option<Subscription>,
744 width: Option<Pixels>,
745 height: Option<Pixels>,
746 zoomed: bool,
747 pending_serialization: Option<Task<Result<()>>>,
748 onboarding: Entity<AgentPanelOnboarding>,
749 selected_agent_type: AgentType,
750 start_thread_in: StartThreadIn,
751 worktree_creation_status: Option<WorktreeCreationStatus>,
752 _thread_view_subscription: Option<Subscription>,
753 _active_thread_focus_subscription: Option<Subscription>,
754 _worktree_creation_task: Option<Task<()>>,
755 show_trust_workspace_message: bool,
756 last_configuration_error_telemetry: Option<String>,
757 on_boarding_upsell_dismissed: AtomicBool,
758 _active_view_observation: Option<Subscription>,
759}
760
761impl AgentPanel {
762 fn serialize(&mut self, cx: &mut App) {
763 let Some(workspace_id) = self.workspace_id else {
764 return;
765 };
766
767 let width = self.width;
768 let selected_agent_type = self.selected_agent_type.clone();
769 let start_thread_in = Some(self.start_thread_in);
770
771 let last_active_thread = self.active_agent_thread(cx).map(|thread| {
772 let thread = thread.read(cx);
773 let title = thread.title();
774 let work_dirs = thread.work_dirs().cloned();
775 SerializedActiveThread {
776 session_id: thread.session_id().0.to_string(),
777 agent_type: self.selected_agent_type.clone(),
778 title: if title.as_ref() != DEFAULT_THREAD_TITLE {
779 Some(title.to_string())
780 } else {
781 None
782 },
783 work_dirs: work_dirs.map(|dirs| dirs.serialize()),
784 }
785 });
786
787 let kvp = KeyValueStore::global(cx);
788 self.pending_serialization = Some(cx.background_spawn(async move {
789 save_serialized_panel(
790 workspace_id,
791 SerializedAgentPanel {
792 width,
793 selected_agent: Some(selected_agent_type),
794 last_active_thread,
795 start_thread_in,
796 },
797 kvp,
798 )
799 .await?;
800 anyhow::Ok(())
801 }));
802 }
803
804 pub fn load(
805 workspace: WeakEntity<Workspace>,
806 prompt_builder: Arc<PromptBuilder>,
807 mut cx: AsyncWindowContext,
808 ) -> Task<Result<Entity<Self>>> {
809 let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
810 let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
811 cx.spawn(async move |cx| {
812 let prompt_store = match prompt_store {
813 Ok(prompt_store) => prompt_store.await.ok(),
814 Err(_) => None,
815 };
816 let workspace_id = workspace
817 .read_with(cx, |workspace, _| workspace.database_id())
818 .ok()
819 .flatten();
820
821 let serialized_panel = cx
822 .background_spawn(async move {
823 kvp.and_then(|kvp| {
824 workspace_id
825 .and_then(|id| read_serialized_panel(id, &kvp))
826 .or_else(|| read_legacy_serialized_panel(&kvp))
827 })
828 })
829 .await;
830
831 let slash_commands = Arc::new(SlashCommandWorkingSet::default());
832 let text_thread_store = workspace
833 .update(cx, |workspace, cx| {
834 let project = workspace.project().clone();
835 assistant_text_thread::TextThreadStore::new(
836 project,
837 prompt_builder,
838 slash_commands,
839 cx,
840 )
841 })?
842 .await?;
843
844 let last_active_thread = if let Some(thread_info) = serialized_panel
845 .as_ref()
846 .and_then(|p| p.last_active_thread.as_ref())
847 {
848 if thread_info.agent_type.is_native() {
849 let session_id = acp::SessionId::new(thread_info.session_id.clone());
850 let load_result = cx.update(|_window, cx| {
851 let thread_store = ThreadStore::global(cx);
852 thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
853 });
854 let thread_exists = if let Ok(task) = load_result {
855 task.await.ok().flatten().is_some()
856 } else {
857 false
858 };
859 if thread_exists {
860 Some(thread_info)
861 } else {
862 log::warn!(
863 "last active thread {} not found in database, skipping restoration",
864 thread_info.session_id
865 );
866 None
867 }
868 } else {
869 Some(thread_info)
870 }
871 } else {
872 None
873 };
874
875 let panel = workspace.update_in(cx, |workspace, window, cx| {
876 let panel =
877 cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
878
879 if let Some(serialized_panel) = &serialized_panel {
880 panel.update(cx, |panel, cx| {
881 panel.width = serialized_panel.width.map(|w| w.round());
882 if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
883 panel.selected_agent_type = selected_agent;
884 }
885 if let Some(start_thread_in) = serialized_panel.start_thread_in {
886 let is_worktree_flag_enabled =
887 cx.has_flag::<AgentV2FeatureFlag>();
888 let is_valid = match &start_thread_in {
889 StartThreadIn::LocalProject => true,
890 StartThreadIn::NewWorktree => {
891 let project = panel.project.read(cx);
892 is_worktree_flag_enabled && !project.is_via_collab()
893 }
894 };
895 if is_valid {
896 panel.start_thread_in = start_thread_in;
897 } else {
898 log::info!(
899 "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
900 start_thread_in,
901 );
902 }
903 }
904 cx.notify();
905 });
906 }
907
908 if let Some(thread_info) = last_active_thread {
909 let agent_type = thread_info.agent_type.clone();
910 panel.update(cx, |panel, cx| {
911 panel.selected_agent_type = agent_type;
912 if let Some(agent) = panel.selected_agent() {
913 panel.load_agent_thread(
914 agent,
915 thread_info.session_id.clone().into(),
916 thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
917 thread_info.title.as_ref().map(|t| t.clone().into()),
918 false,
919 window,
920 cx,
921 );
922 }
923 });
924 }
925 panel
926 })?;
927
928 Ok(panel)
929 })
930 }
931
932 pub(crate) fn new(
933 workspace: &Workspace,
934 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
935 prompt_store: Option<Entity<PromptStore>>,
936 window: &mut Window,
937 cx: &mut Context<Self>,
938 ) -> Self {
939 let fs = workspace.app_state().fs.clone();
940 let user_store = workspace.app_state().user_store.clone();
941 let project = workspace.project();
942 let language_registry = project.read(cx).languages().clone();
943 let client = workspace.client().clone();
944 let workspace_id = workspace.database_id();
945 let workspace = workspace.weak_handle();
946
947 let context_server_registry =
948 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
949
950 let thread_store = ThreadStore::global(cx);
951 let text_thread_history =
952 cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
953
954 cx.subscribe_in(
955 &text_thread_history,
956 window,
957 |this, _, event, window, cx| match event {
958 TextThreadHistoryEvent::Open(thread) => {
959 this.open_saved_text_thread(thread.path.clone(), window, cx)
960 .detach_and_log_err(cx);
961 }
962 },
963 )
964 .detach();
965
966 let active_view = ActiveView::Uninitialized;
967
968 let weak_panel = cx.entity().downgrade();
969
970 window.defer(cx, move |window, cx| {
971 let panel = weak_panel.clone();
972 let agent_navigation_menu =
973 ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
974 if let Some(panel) = panel.upgrade() {
975 if let Some(history) = panel
976 .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
977 {
978 let view_all_label = match history {
979 History::AgentThreads { .. } => "View All",
980 History::TextThreads => "View All Text Threads",
981 };
982 menu = Self::populate_recently_updated_menu_section(
983 menu, panel, history, cx,
984 );
985 menu = menu.action(view_all_label, Box::new(OpenHistory));
986 }
987 }
988
989 menu = menu
990 .fixed_width(px(320.).into())
991 .keep_open_on_confirm(false)
992 .key_context("NavigationMenu");
993
994 menu
995 });
996 weak_panel
997 .update(cx, |panel, cx| {
998 cx.subscribe_in(
999 &agent_navigation_menu,
1000 window,
1001 |_, menu, _: &DismissEvent, window, cx| {
1002 menu.update(cx, |menu, _| {
1003 menu.clear_selected();
1004 });
1005 cx.focus_self(window);
1006 },
1007 )
1008 .detach();
1009 panel.agent_navigation_menu = Some(agent_navigation_menu);
1010 })
1011 .ok();
1012 });
1013
1014 let weak_panel = cx.entity().downgrade();
1015 let onboarding = cx.new(|cx| {
1016 AgentPanelOnboarding::new(
1017 user_store.clone(),
1018 client,
1019 move |_window, cx| {
1020 weak_panel
1021 .update(cx, |panel, _| {
1022 panel
1023 .on_boarding_upsell_dismissed
1024 .store(true, Ordering::Release);
1025 })
1026 .ok();
1027 OnboardingUpsell::set_dismissed(true, cx);
1028 },
1029 cx,
1030 )
1031 });
1032
1033 // Subscribe to extension events to sync agent servers when extensions change
1034 let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
1035 {
1036 Some(
1037 cx.subscribe(&extension_events, |this, _source, event, cx| match event {
1038 extension::Event::ExtensionInstalled(_)
1039 | extension::Event::ExtensionUninstalled(_)
1040 | extension::Event::ExtensionsInstalledChanged => {
1041 this.sync_agent_servers_from_extensions(cx);
1042 }
1043 _ => {}
1044 }),
1045 )
1046 } else {
1047 None
1048 };
1049
1050 let connection_store = cx.new(|cx| {
1051 let mut store = AgentConnectionStore::new(project.clone(), cx);
1052 // Register the native agent right away, so that it is available for
1053 // the inline assistant etc.
1054 store.request_connection(
1055 Agent::NativeAgent,
1056 Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
1057 cx,
1058 );
1059 store
1060 });
1061 let mut panel = Self {
1062 workspace_id,
1063 active_view,
1064 workspace,
1065 user_store,
1066 project: project.clone(),
1067 fs: fs.clone(),
1068 language_registry,
1069 text_thread_store,
1070 prompt_store,
1071 connection_store,
1072 configuration: None,
1073 configuration_subscription: None,
1074 focus_handle: cx.focus_handle(),
1075 context_server_registry,
1076 previous_view: None,
1077 background_threads: HashMap::default(),
1078 new_thread_menu_handle: PopoverMenuHandle::default(),
1079 start_thread_in_menu_handle: PopoverMenuHandle::default(),
1080 agent_panel_menu_handle: PopoverMenuHandle::default(),
1081 agent_navigation_menu_handle: PopoverMenuHandle::default(),
1082 agent_navigation_menu: None,
1083 _extension_subscription: extension_subscription,
1084 width: None,
1085 height: None,
1086 zoomed: false,
1087 pending_serialization: None,
1088 onboarding,
1089 text_thread_history,
1090 thread_store,
1091 selected_agent_type: AgentType::default(),
1092 start_thread_in: StartThreadIn::default(),
1093 worktree_creation_status: None,
1094 _thread_view_subscription: None,
1095 _active_thread_focus_subscription: None,
1096 _worktree_creation_task: None,
1097 show_trust_workspace_message: false,
1098 last_configuration_error_telemetry: None,
1099 on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
1100 _active_view_observation: None,
1101 };
1102
1103 // Initial sync of agent servers from extensions
1104 panel.sync_agent_servers_from_extensions(cx);
1105 panel
1106 }
1107
1108 pub fn toggle_focus(
1109 workspace: &mut Workspace,
1110 _: &ToggleFocus,
1111 window: &mut Window,
1112 cx: &mut Context<Workspace>,
1113 ) {
1114 if workspace
1115 .panel::<Self>(cx)
1116 .is_some_and(|panel| panel.read(cx).enabled(cx))
1117 {
1118 workspace.toggle_panel_focus::<Self>(window, cx);
1119 }
1120 }
1121
1122 pub fn toggle(
1123 workspace: &mut Workspace,
1124 _: &Toggle,
1125 window: &mut Window,
1126 cx: &mut Context<Workspace>,
1127 ) {
1128 if workspace
1129 .panel::<Self>(cx)
1130 .is_some_and(|panel| panel.read(cx).enabled(cx))
1131 {
1132 if !workspace.toggle_panel_focus::<Self>(window, cx) {
1133 workspace.close_panel::<Self>(window, cx);
1134 }
1135 }
1136 }
1137
1138 pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
1139 &self.prompt_store
1140 }
1141
1142 pub fn thread_store(&self) -> &Entity<ThreadStore> {
1143 &self.thread_store
1144 }
1145
1146 pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
1147 &self.connection_store
1148 }
1149
1150 pub fn open_thread(
1151 &mut self,
1152 session_id: acp::SessionId,
1153 work_dirs: Option<PathList>,
1154 title: Option<SharedString>,
1155 window: &mut Window,
1156 cx: &mut Context<Self>,
1157 ) {
1158 self.external_thread(
1159 Some(crate::Agent::NativeAgent),
1160 Some(session_id),
1161 work_dirs,
1162 title,
1163 None,
1164 true,
1165 window,
1166 cx,
1167 );
1168 }
1169
1170 pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
1171 &self.context_server_registry
1172 }
1173
1174 pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
1175 let workspace_read = workspace.read(cx);
1176
1177 workspace_read
1178 .panel::<AgentPanel>(cx)
1179 .map(|panel| {
1180 let panel_id = Entity::entity_id(&panel);
1181
1182 workspace_read.all_docks().iter().any(|dock| {
1183 dock.read(cx)
1184 .visible_panel()
1185 .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1186 })
1187 })
1188 .unwrap_or(false)
1189 }
1190
1191 pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1192 self.new_agent_thread(AgentType::NativeAgent, window, cx);
1193 }
1194
1195 fn new_native_agent_thread_from_summary(
1196 &mut self,
1197 action: &NewNativeAgentThreadFromSummary,
1198 window: &mut Window,
1199 cx: &mut Context<Self>,
1200 ) {
1201 let session_id = action.from_session_id.clone();
1202
1203 let Some(history) = self
1204 .connection_store
1205 .read(cx)
1206 .entry(&Agent::NativeAgent)
1207 .and_then(|e| e.read(cx).history().cloned())
1208 else {
1209 debug_panic!("Native agent is not registered");
1210 return;
1211 };
1212
1213 cx.spawn_in(window, async move |this, cx| {
1214 this.update_in(cx, |this, window, cx| {
1215 let thread = history
1216 .read(cx)
1217 .session_for_id(&session_id)
1218 .context("Session not found")?;
1219
1220 this.external_thread(
1221 Some(Agent::NativeAgent),
1222 None,
1223 None,
1224 None,
1225 Some(AgentInitialContent::ThreadSummary {
1226 session_id: thread.session_id,
1227 title: thread.title,
1228 }),
1229 true,
1230 window,
1231 cx,
1232 );
1233 anyhow::Ok(())
1234 })
1235 })
1236 .detach_and_log_err(cx);
1237 }
1238
1239 fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1240 telemetry::event!("Agent Thread Started", agent = "zed-text");
1241
1242 let context = self
1243 .text_thread_store
1244 .update(cx, |context_store, cx| context_store.create(cx));
1245 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx)
1246 .log_err()
1247 .flatten();
1248
1249 let text_thread_editor = cx.new(|cx| {
1250 let mut editor = TextThreadEditor::for_text_thread(
1251 context,
1252 self.fs.clone(),
1253 self.workspace.clone(),
1254 self.project.clone(),
1255 lsp_adapter_delegate,
1256 window,
1257 cx,
1258 );
1259 editor.insert_default_prompt(window, cx);
1260 editor
1261 });
1262
1263 if self.selected_agent_type != AgentType::TextThread {
1264 self.selected_agent_type = AgentType::TextThread;
1265 self.serialize(cx);
1266 }
1267
1268 self.set_active_view(
1269 ActiveView::text_thread(
1270 text_thread_editor.clone(),
1271 self.language_registry.clone(),
1272 window,
1273 cx,
1274 ),
1275 true,
1276 window,
1277 cx,
1278 );
1279 text_thread_editor.focus_handle(cx).focus(window, cx);
1280 }
1281
1282 fn external_thread(
1283 &mut self,
1284 agent_choice: Option<crate::Agent>,
1285 resume_session_id: Option<acp::SessionId>,
1286 work_dirs: Option<PathList>,
1287 title: Option<SharedString>,
1288 initial_content: Option<AgentInitialContent>,
1289 focus: bool,
1290 window: &mut Window,
1291 cx: &mut Context<Self>,
1292 ) {
1293 let workspace = self.workspace.clone();
1294 let project = self.project.clone();
1295 let fs = self.fs.clone();
1296 let is_via_collab = self.project.read(cx).is_via_collab();
1297
1298 const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
1299
1300 #[derive(Serialize, Deserialize)]
1301 struct LastUsedExternalAgent {
1302 agent: crate::Agent,
1303 }
1304
1305 let thread_store = self.thread_store.clone();
1306 let kvp = KeyValueStore::global(cx);
1307
1308 if let Some(agent) = agent_choice {
1309 cx.background_spawn({
1310 let agent = agent.clone();
1311 let kvp = kvp;
1312 async move {
1313 if let Some(serialized) =
1314 serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
1315 {
1316 kvp.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
1317 .await
1318 .log_err();
1319 }
1320 }
1321 })
1322 .detach();
1323
1324 let server = agent.server(fs, thread_store);
1325 self.create_agent_thread(
1326 server,
1327 resume_session_id,
1328 work_dirs,
1329 title,
1330 initial_content,
1331 workspace,
1332 project,
1333 agent,
1334 focus,
1335 window,
1336 cx,
1337 );
1338 } else {
1339 cx.spawn_in(window, async move |this, cx| {
1340 let ext_agent = if is_via_collab {
1341 Agent::NativeAgent
1342 } else {
1343 cx.background_spawn(async move { kvp.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) })
1344 .await
1345 .log_err()
1346 .flatten()
1347 .and_then(|value| {
1348 serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
1349 })
1350 .map(|agent| agent.agent)
1351 .unwrap_or(Agent::NativeAgent)
1352 };
1353
1354 let server = ext_agent.server(fs, thread_store);
1355 this.update_in(cx, |agent_panel, window, cx| {
1356 agent_panel.create_agent_thread(
1357 server,
1358 resume_session_id,
1359 work_dirs,
1360 title,
1361 initial_content,
1362 workspace,
1363 project,
1364 ext_agent,
1365 focus,
1366 window,
1367 cx,
1368 );
1369 })?;
1370
1371 anyhow::Ok(())
1372 })
1373 .detach_and_log_err(cx);
1374 }
1375 }
1376
1377 fn deploy_rules_library(
1378 &mut self,
1379 action: &OpenRulesLibrary,
1380 _window: &mut Window,
1381 cx: &mut Context<Self>,
1382 ) {
1383 open_rules_library(
1384 self.language_registry.clone(),
1385 Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1386 Rc::new(|| {
1387 Rc::new(SlashCommandCompletionProvider::new(
1388 Arc::new(SlashCommandWorkingSet::default()),
1389 None,
1390 None,
1391 ))
1392 }),
1393 action
1394 .prompt_to_select
1395 .map(|uuid| UserPromptId(uuid).into()),
1396 cx,
1397 )
1398 .detach_and_log_err(cx);
1399 }
1400
1401 fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1402 let Some(conversation_view) = self.active_conversation_view() else {
1403 return;
1404 };
1405
1406 let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else {
1407 return;
1408 };
1409
1410 active_thread.update(cx, |active_thread, cx| {
1411 active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1412 active_thread.focus_handle(cx).focus(window, cx);
1413 })
1414 }
1415
1416 fn has_history_for_selected_agent(&self, cx: &App) -> bool {
1417 match &self.selected_agent_type {
1418 AgentType::TextThread | AgentType::NativeAgent => true,
1419 AgentType::Custom { id } => {
1420 let agent = Agent::Custom { id: id.clone() };
1421 self.connection_store
1422 .read(cx)
1423 .entry(&agent)
1424 .map_or(false, |entry| entry.read(cx).history().is_some())
1425 }
1426 }
1427 }
1428
1429 fn history_for_selected_agent(
1430 &self,
1431 window: &mut Window,
1432 cx: &mut Context<Self>,
1433 ) -> Option<History> {
1434 match &self.selected_agent_type {
1435 AgentType::TextThread => Some(History::TextThreads),
1436 AgentType::NativeAgent => {
1437 let history = self
1438 .connection_store
1439 .read(cx)
1440 .entry(&Agent::NativeAgent)?
1441 .read(cx)
1442 .history()?
1443 .clone();
1444
1445 Some(History::AgentThreads {
1446 view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx),
1447 })
1448 }
1449 AgentType::Custom { id, .. } => {
1450 let agent = Agent::Custom { id: id.clone() };
1451 let history = self
1452 .connection_store
1453 .read(cx)
1454 .entry(&agent)?
1455 .read(cx)
1456 .history()?
1457 .clone();
1458 Some(History::AgentThreads {
1459 view: self.create_thread_history_view(agent, history, window, cx),
1460 })
1461 }
1462 }
1463 }
1464
1465 fn create_thread_history_view(
1466 &self,
1467 agent: Agent,
1468 history: Entity<ThreadHistory>,
1469 window: &mut Window,
1470 cx: &mut Context<Self>,
1471 ) -> Entity<ThreadHistoryView> {
1472 let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
1473 cx.subscribe_in(
1474 &view,
1475 window,
1476 move |this, _, event, window, cx| match event {
1477 ThreadHistoryViewEvent::Open(thread) => {
1478 this.load_agent_thread(
1479 agent.clone(),
1480 thread.session_id.clone(),
1481 thread.work_dirs.clone(),
1482 thread.title.clone(),
1483 true,
1484 window,
1485 cx,
1486 );
1487 }
1488 },
1489 )
1490 .detach();
1491 view
1492 }
1493
1494 fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1495 let Some(history) = self.history_for_selected_agent(window, cx) else {
1496 return;
1497 };
1498
1499 if let ActiveView::History {
1500 history: active_history,
1501 } = &self.active_view
1502 {
1503 if active_history == &history {
1504 if let Some(previous_view) = self.previous_view.take() {
1505 self.set_active_view(previous_view, true, window, cx);
1506 }
1507 return;
1508 }
1509 }
1510
1511 self.set_active_view(ActiveView::History { history }, true, window, cx);
1512 cx.notify();
1513 }
1514
1515 pub(crate) fn open_saved_text_thread(
1516 &mut self,
1517 path: Arc<Path>,
1518 window: &mut Window,
1519 cx: &mut Context<Self>,
1520 ) -> Task<Result<()>> {
1521 let text_thread_task = self
1522 .text_thread_store
1523 .update(cx, |store, cx| store.open_local(path, cx));
1524 cx.spawn_in(window, async move |this, cx| {
1525 let text_thread = text_thread_task.await?;
1526 this.update_in(cx, |this, window, cx| {
1527 this.open_text_thread(text_thread, window, cx);
1528 })
1529 })
1530 }
1531
1532 pub(crate) fn open_text_thread(
1533 &mut self,
1534 text_thread: Entity<TextThread>,
1535 window: &mut Window,
1536 cx: &mut Context<Self>,
1537 ) {
1538 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project.clone(), cx)
1539 .log_err()
1540 .flatten();
1541 let editor = cx.new(|cx| {
1542 TextThreadEditor::for_text_thread(
1543 text_thread,
1544 self.fs.clone(),
1545 self.workspace.clone(),
1546 self.project.clone(),
1547 lsp_adapter_delegate,
1548 window,
1549 cx,
1550 )
1551 });
1552
1553 if self.selected_agent_type != AgentType::TextThread {
1554 self.selected_agent_type = AgentType::TextThread;
1555 self.serialize(cx);
1556 }
1557
1558 self.set_active_view(
1559 ActiveView::text_thread(editor, self.language_registry.clone(), window, cx),
1560 true,
1561 window,
1562 cx,
1563 );
1564 }
1565
1566 pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1567 match self.active_view {
1568 ActiveView::Configuration | ActiveView::History { .. } => {
1569 if let Some(previous_view) = self.previous_view.take() {
1570 self.set_active_view(previous_view, true, window, cx);
1571 }
1572 cx.notify();
1573 }
1574 _ => {}
1575 }
1576 }
1577
1578 pub fn toggle_navigation_menu(
1579 &mut self,
1580 _: &ToggleNavigationMenu,
1581 window: &mut Window,
1582 cx: &mut Context<Self>,
1583 ) {
1584 if !self.has_history_for_selected_agent(cx) {
1585 return;
1586 }
1587 self.agent_navigation_menu_handle.toggle(window, cx);
1588 }
1589
1590 pub fn toggle_options_menu(
1591 &mut self,
1592 _: &ToggleOptionsMenu,
1593 window: &mut Window,
1594 cx: &mut Context<Self>,
1595 ) {
1596 self.agent_panel_menu_handle.toggle(window, cx);
1597 }
1598
1599 pub fn toggle_new_thread_menu(
1600 &mut self,
1601 _: &ToggleNewThreadMenu,
1602 window: &mut Window,
1603 cx: &mut Context<Self>,
1604 ) {
1605 self.new_thread_menu_handle.toggle(window, cx);
1606 }
1607
1608 pub fn increase_font_size(
1609 &mut self,
1610 action: &IncreaseBufferFontSize,
1611 _: &mut Window,
1612 cx: &mut Context<Self>,
1613 ) {
1614 self.handle_font_size_action(action.persist, px(1.0), cx);
1615 }
1616
1617 pub fn decrease_font_size(
1618 &mut self,
1619 action: &DecreaseBufferFontSize,
1620 _: &mut Window,
1621 cx: &mut Context<Self>,
1622 ) {
1623 self.handle_font_size_action(action.persist, px(-1.0), cx);
1624 }
1625
1626 fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1627 match self.active_view.which_font_size_used() {
1628 WhichFontSize::AgentFont => {
1629 if persist {
1630 update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1631 let agent_ui_font_size =
1632 ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1633 let agent_buffer_font_size =
1634 ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1635
1636 let _ = settings
1637 .theme
1638 .agent_ui_font_size
1639 .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into());
1640 let _ = settings.theme.agent_buffer_font_size.insert(
1641 f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(),
1642 );
1643 });
1644 } else {
1645 theme::adjust_agent_ui_font_size(cx, |size| size + delta);
1646 theme::adjust_agent_buffer_font_size(cx, |size| size + delta);
1647 }
1648 }
1649 WhichFontSize::BufferFont => {
1650 // Prompt editor uses the buffer font size, so allow the action to propagate to the
1651 // default handler that changes that font size.
1652 cx.propagate();
1653 }
1654 WhichFontSize::None => {}
1655 }
1656 }
1657
1658 pub fn reset_font_size(
1659 &mut self,
1660 action: &ResetBufferFontSize,
1661 _: &mut Window,
1662 cx: &mut Context<Self>,
1663 ) {
1664 if action.persist {
1665 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1666 settings.theme.agent_ui_font_size = None;
1667 settings.theme.agent_buffer_font_size = None;
1668 });
1669 } else {
1670 theme::reset_agent_ui_font_size(cx);
1671 theme::reset_agent_buffer_font_size(cx);
1672 }
1673 }
1674
1675 pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1676 theme::reset_agent_ui_font_size(cx);
1677 theme::reset_agent_buffer_font_size(cx);
1678 }
1679
1680 pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1681 if self.zoomed {
1682 cx.emit(PanelEvent::ZoomOut);
1683 } else {
1684 if !self.focus_handle(cx).contains_focused(window, cx) {
1685 cx.focus_self(window);
1686 }
1687 cx.emit(PanelEvent::ZoomIn);
1688 }
1689 }
1690
1691 pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1692 let agent_server_store = self.project.read(cx).agent_server_store().clone();
1693 let context_server_store = self.project.read(cx).context_server_store();
1694 let fs = self.fs.clone();
1695
1696 self.set_active_view(ActiveView::Configuration, true, window, cx);
1697 self.configuration = Some(cx.new(|cx| {
1698 AgentConfiguration::new(
1699 fs,
1700 agent_server_store,
1701 context_server_store,
1702 self.context_server_registry.clone(),
1703 self.language_registry.clone(),
1704 self.workspace.clone(),
1705 window,
1706 cx,
1707 )
1708 }));
1709
1710 if let Some(configuration) = self.configuration.as_ref() {
1711 self.configuration_subscription = Some(cx.subscribe_in(
1712 configuration,
1713 window,
1714 Self::handle_agent_configuration_event,
1715 ));
1716
1717 configuration.focus_handle(cx).focus(window, cx);
1718 }
1719 }
1720
1721 pub(crate) fn open_active_thread_as_markdown(
1722 &mut self,
1723 _: &OpenActiveThreadAsMarkdown,
1724 window: &mut Window,
1725 cx: &mut Context<Self>,
1726 ) {
1727 if let Some(workspace) = self.workspace.upgrade()
1728 && let Some(conversation_view) = self.active_conversation_view()
1729 && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned()
1730 {
1731 active_thread.update(cx, |thread, cx| {
1732 thread
1733 .open_thread_as_markdown(workspace, window, cx)
1734 .detach_and_log_err(cx);
1735 });
1736 }
1737 }
1738
1739 fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1740 let Some(thread) = self.active_native_agent_thread(cx) else {
1741 Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1742 return;
1743 };
1744
1745 let workspace = self.workspace.clone();
1746 let load_task = thread.read(cx).to_db(cx);
1747
1748 cx.spawn_in(window, async move |_this, cx| {
1749 let db_thread = load_task.await;
1750 let shared_thread = SharedThread::from_db_thread(&db_thread);
1751 let thread_data = shared_thread.to_bytes()?;
1752 let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1753
1754 cx.update(|_window, cx| {
1755 cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1756 if let Some(workspace) = workspace.upgrade() {
1757 workspace.update(cx, |workspace, cx| {
1758 struct ThreadCopiedToast;
1759 workspace.show_toast(
1760 workspace::Toast::new(
1761 workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1762 "Thread copied to clipboard (base64 encoded)",
1763 )
1764 .autohide(),
1765 cx,
1766 );
1767 });
1768 }
1769 })?;
1770
1771 anyhow::Ok(())
1772 })
1773 .detach_and_log_err(cx);
1774 }
1775
1776 fn show_deferred_toast(
1777 workspace: &WeakEntity<workspace::Workspace>,
1778 message: &'static str,
1779 cx: &mut App,
1780 ) {
1781 let workspace = workspace.clone();
1782 cx.defer(move |cx| {
1783 if let Some(workspace) = workspace.upgrade() {
1784 workspace.update(cx, |workspace, cx| {
1785 struct ClipboardToast;
1786 workspace.show_toast(
1787 workspace::Toast::new(
1788 workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1789 message,
1790 )
1791 .autohide(),
1792 cx,
1793 );
1794 });
1795 }
1796 });
1797 }
1798
1799 fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1800 let Some(clipboard) = cx.read_from_clipboard() else {
1801 Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1802 return;
1803 };
1804
1805 let Some(encoded) = clipboard.text() else {
1806 Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1807 return;
1808 };
1809
1810 let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1811 {
1812 Ok(data) => data,
1813 Err(_) => {
1814 Self::show_deferred_toast(
1815 &self.workspace,
1816 "Failed to decode clipboard content (expected base64)",
1817 cx,
1818 );
1819 return;
1820 }
1821 };
1822
1823 let shared_thread = match SharedThread::from_bytes(&thread_data) {
1824 Ok(thread) => thread,
1825 Err(_) => {
1826 Self::show_deferred_toast(
1827 &self.workspace,
1828 "Failed to parse thread data from clipboard",
1829 cx,
1830 );
1831 return;
1832 }
1833 };
1834
1835 let db_thread = shared_thread.to_db_thread();
1836 let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1837 let thread_store = self.thread_store.clone();
1838 let title = db_thread.title.clone();
1839 let workspace = self.workspace.clone();
1840
1841 cx.spawn_in(window, async move |this, cx| {
1842 thread_store
1843 .update(&mut cx.clone(), |store, cx| {
1844 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1845 })
1846 .await?;
1847
1848 this.update_in(cx, |this, window, cx| {
1849 this.open_thread(session_id, None, Some(title), window, cx);
1850 })?;
1851
1852 this.update_in(cx, |_, _window, cx| {
1853 if let Some(workspace) = workspace.upgrade() {
1854 workspace.update(cx, |workspace, cx| {
1855 struct ThreadLoadedToast;
1856 workspace.show_toast(
1857 workspace::Toast::new(
1858 workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1859 "Thread loaded from clipboard",
1860 )
1861 .autohide(),
1862 cx,
1863 );
1864 });
1865 }
1866 })?;
1867
1868 anyhow::Ok(())
1869 })
1870 .detach_and_log_err(cx);
1871 }
1872
1873 fn handle_agent_configuration_event(
1874 &mut self,
1875 _entity: &Entity<AgentConfiguration>,
1876 event: &AssistantConfigurationEvent,
1877 window: &mut Window,
1878 cx: &mut Context<Self>,
1879 ) {
1880 match event {
1881 AssistantConfigurationEvent::NewThread(provider) => {
1882 if LanguageModelRegistry::read_global(cx)
1883 .default_model()
1884 .is_none_or(|model| model.provider.id() != provider.id())
1885 && let Some(model) = provider.default_model(cx)
1886 {
1887 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1888 let provider = model.provider_id().0.to_string();
1889 let enable_thinking = model.supports_thinking();
1890 let effort = model
1891 .default_effort_level()
1892 .map(|effort| effort.value.to_string());
1893 let model = model.id().0.to_string();
1894 settings
1895 .agent
1896 .get_or_insert_default()
1897 .set_model(LanguageModelSelection {
1898 provider: LanguageModelProviderSetting(provider),
1899 model,
1900 enable_thinking,
1901 effort,
1902 })
1903 });
1904 }
1905
1906 self.new_thread(&NewThread, window, cx);
1907 if let Some((thread, model)) = self
1908 .active_native_agent_thread(cx)
1909 .zip(provider.default_model(cx))
1910 {
1911 thread.update(cx, |thread, cx| {
1912 thread.set_model(model, cx);
1913 });
1914 }
1915 }
1916 }
1917 }
1918
1919 pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
1920 match &self.active_view {
1921 ActiveView::AgentThread { conversation_view } => Some(conversation_view),
1922 _ => None,
1923 }
1924 }
1925
1926 pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
1927 let server_view = self.active_conversation_view()?;
1928 server_view.read(cx).active_thread().cloned()
1929 }
1930
1931 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1932 match &self.active_view {
1933 ActiveView::AgentThread {
1934 conversation_view, ..
1935 } => conversation_view
1936 .read(cx)
1937 .active_thread()
1938 .map(|r| r.read(cx).thread.clone()),
1939 _ => None,
1940 }
1941 }
1942
1943 /// Returns the primary thread views for all retained connections: the
1944 pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
1945 self.background_threads.contains_key(session_id)
1946 }
1947
1948 pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
1949 let conversation_views = self
1950 .active_conversation_view()
1951 .into_iter()
1952 .chain(self.background_threads.values());
1953
1954 for conversation_view in conversation_views {
1955 if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
1956 thread_view.update(cx, |view, cx| view.cancel_generation(cx));
1957 return true;
1958 }
1959 }
1960 false
1961 }
1962
1963 /// active thread plus any background threads that are still running or
1964 /// completed but unseen.
1965 pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
1966 let mut views = Vec::new();
1967
1968 if let Some(server_view) = self.active_conversation_view() {
1969 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1970 views.push(thread_view);
1971 }
1972 }
1973
1974 for server_view in self.background_threads.values() {
1975 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1976 views.push(thread_view);
1977 }
1978 }
1979
1980 views
1981 }
1982
1983 fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
1984 let ActiveView::AgentThread { conversation_view } = old_view else {
1985 return;
1986 };
1987
1988 let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
1989 return;
1990 };
1991
1992 self.background_threads
1993 .insert(thread_view.read(cx).id.clone(), conversation_view);
1994 self.cleanup_background_threads(cx);
1995 }
1996
1997 /// We keep threads that are:
1998 /// - Still running
1999 /// - Do not support reloading the full session
2000 /// - Have had the most recent events (up to 5 idle threads)
2001 fn cleanup_background_threads(&mut self, cx: &App) {
2002 let mut potential_removals = self
2003 .background_threads
2004 .iter()
2005 .filter(|(_id, view)| {
2006 let Some(thread_view) = view.read(cx).root_thread(cx) else {
2007 return true;
2008 };
2009 let thread = thread_view.read(cx).thread.read(cx);
2010 thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
2011 })
2012 .collect::<Vec<_>>();
2013
2014 const MAX_IDLE_BACKGROUND_THREADS: usize = 5;
2015
2016 potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
2017 let n = potential_removals
2018 .len()
2019 .saturating_sub(MAX_IDLE_BACKGROUND_THREADS);
2020 let to_remove = potential_removals
2021 .into_iter()
2022 .map(|(id, _)| id.clone())
2023 .take(n)
2024 .collect::<Vec<_>>();
2025 for id in to_remove {
2026 self.background_threads.remove(&id);
2027 }
2028 }
2029
2030 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2031 match &self.active_view {
2032 ActiveView::AgentThread {
2033 conversation_view, ..
2034 } => conversation_view.read(cx).as_native_thread(cx),
2035 _ => None,
2036 }
2037 }
2038
2039 pub(crate) fn active_text_thread_editor(&self) -> Option<Entity<TextThreadEditor>> {
2040 match &self.active_view {
2041 ActiveView::TextThread {
2042 text_thread_editor, ..
2043 } => Some(text_thread_editor.clone()),
2044 _ => None,
2045 }
2046 }
2047
2048 fn set_active_view(
2049 &mut self,
2050 new_view: ActiveView,
2051 focus: bool,
2052 window: &mut Window,
2053 cx: &mut Context<Self>,
2054 ) {
2055 let was_in_agent_history = matches!(
2056 self.active_view,
2057 ActiveView::History {
2058 history: History::AgentThreads { .. }
2059 }
2060 );
2061 let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
2062 let current_is_history = matches!(self.active_view, ActiveView::History { .. });
2063 let new_is_history = matches!(new_view, ActiveView::History { .. });
2064
2065 let current_is_config = matches!(self.active_view, ActiveView::Configuration);
2066 let new_is_config = matches!(new_view, ActiveView::Configuration);
2067
2068 let current_is_overlay = current_is_history || current_is_config;
2069 let new_is_overlay = new_is_history || new_is_config;
2070
2071 if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
2072 self.active_view = new_view;
2073 } else if !current_is_overlay && new_is_overlay {
2074 self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
2075 } else {
2076 let old_view = std::mem::replace(&mut self.active_view, new_view);
2077 if !new_is_overlay {
2078 if let Some(previous) = self.previous_view.take() {
2079 self.retain_running_thread(previous, cx);
2080 }
2081 }
2082 self.retain_running_thread(old_view, cx);
2083 }
2084
2085 // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
2086 // so the panel can intercept the first send for worktree creation.
2087 // Re-subscribe whenever the ConnectionView changes, since the inner
2088 // ThreadView may have been replaced (e.g. navigating between threads).
2089 self._active_view_observation = match &self.active_view {
2090 ActiveView::AgentThread { conversation_view } => {
2091 self._thread_view_subscription =
2092 Self::subscribe_to_active_thread_view(conversation_view, window, cx);
2093 let focus_handle = conversation_view.focus_handle(cx);
2094 self._active_thread_focus_subscription =
2095 Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
2096 cx.emit(AgentPanelEvent::ThreadFocused);
2097 cx.notify();
2098 }));
2099 Some(cx.observe_in(
2100 conversation_view,
2101 window,
2102 |this, server_view, window, cx| {
2103 this._thread_view_subscription =
2104 Self::subscribe_to_active_thread_view(&server_view, window, cx);
2105 cx.emit(AgentPanelEvent::ActiveViewChanged);
2106 this.serialize(cx);
2107 cx.notify();
2108 },
2109 ))
2110 }
2111 _ => {
2112 self._thread_view_subscription = None;
2113 self._active_thread_focus_subscription = None;
2114 None
2115 }
2116 };
2117
2118 if let ActiveView::History { history } = &self.active_view {
2119 if !was_in_agent_history && let History::AgentThreads { view } = history {
2120 view.update(cx, |view, cx| {
2121 view.history()
2122 .update(cx, |history, cx| history.refresh_full_history(cx))
2123 });
2124 }
2125 }
2126
2127 if focus {
2128 self.focus_handle(cx).focus(window, cx);
2129 }
2130 cx.emit(AgentPanelEvent::ActiveViewChanged);
2131 }
2132
2133 fn populate_recently_updated_menu_section(
2134 mut menu: ContextMenu,
2135 panel: Entity<Self>,
2136 history: History,
2137 cx: &mut Context<ContextMenu>,
2138 ) -> ContextMenu {
2139 match history {
2140 History::AgentThreads { view } => {
2141 let entries = view
2142 .read(cx)
2143 .history()
2144 .read(cx)
2145 .sessions()
2146 .iter()
2147 .take(RECENTLY_UPDATED_MENU_LIMIT)
2148 .cloned()
2149 .collect::<Vec<_>>();
2150
2151 if entries.is_empty() {
2152 return menu;
2153 }
2154
2155 menu = menu.header("Recently Updated");
2156
2157 for entry in entries {
2158 let title = entry
2159 .title
2160 .as_ref()
2161 .filter(|title| !title.is_empty())
2162 .cloned()
2163 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
2164
2165 menu = menu.entry(title, None, {
2166 let panel = panel.downgrade();
2167 let entry = entry.clone();
2168 move |window, cx| {
2169 let entry = entry.clone();
2170 panel
2171 .update(cx, move |this, cx| {
2172 if let Some(agent) = this.selected_agent() {
2173 this.load_agent_thread(
2174 agent,
2175 entry.session_id.clone(),
2176 entry.work_dirs.clone(),
2177 entry.title.clone(),
2178 true,
2179 window,
2180 cx,
2181 );
2182 }
2183 })
2184 .ok();
2185 }
2186 });
2187 }
2188 }
2189 History::TextThreads => {
2190 let entries = panel
2191 .read(cx)
2192 .text_thread_store
2193 .read(cx)
2194 .ordered_text_threads()
2195 .take(RECENTLY_UPDATED_MENU_LIMIT)
2196 .cloned()
2197 .collect::<Vec<_>>();
2198
2199 if entries.is_empty() {
2200 return menu;
2201 }
2202
2203 menu = menu.header("Recent Text Threads");
2204
2205 for entry in entries {
2206 let title = if entry.title.is_empty() {
2207 SharedString::new_static(DEFAULT_THREAD_TITLE)
2208 } else {
2209 entry.title.clone()
2210 };
2211
2212 menu = menu.entry(title, None, {
2213 let panel = panel.downgrade();
2214 let entry = entry.clone();
2215 move |window, cx| {
2216 let path = entry.path.clone();
2217 panel
2218 .update(cx, move |this, cx| {
2219 this.open_saved_text_thread(path.clone(), window, cx)
2220 .detach_and_log_err(cx);
2221 })
2222 .ok();
2223 }
2224 });
2225 }
2226 }
2227 }
2228
2229 menu.separator()
2230 }
2231
2232 fn subscribe_to_active_thread_view(
2233 server_view: &Entity<ConversationView>,
2234 window: &mut Window,
2235 cx: &mut Context<Self>,
2236 ) -> Option<Subscription> {
2237 server_view.read(cx).active_thread().cloned().map(|tv| {
2238 cx.subscribe_in(
2239 &tv,
2240 window,
2241 |this, view, event: &AcpThreadViewEvent, window, cx| match event {
2242 AcpThreadViewEvent::FirstSendRequested { content } => {
2243 this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
2244 }
2245 },
2246 )
2247 })
2248 }
2249
2250 pub fn start_thread_in(&self) -> &StartThreadIn {
2251 &self.start_thread_in
2252 }
2253
2254 fn set_start_thread_in(
2255 &mut self,
2256 action: &StartThreadIn,
2257 window: &mut Window,
2258 cx: &mut Context<Self>,
2259 ) {
2260 if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::<AgentV2FeatureFlag>() {
2261 return;
2262 }
2263
2264 let new_target = match *action {
2265 StartThreadIn::LocalProject => StartThreadIn::LocalProject,
2266 StartThreadIn::NewWorktree => {
2267 if !self.project_has_git_repository(cx) {
2268 log::error!(
2269 "set_start_thread_in: cannot use NewWorktree without a git repository"
2270 );
2271 return;
2272 }
2273 if self.project.read(cx).is_via_collab() {
2274 log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
2275 return;
2276 }
2277 StartThreadIn::NewWorktree
2278 }
2279 };
2280 self.start_thread_in = new_target;
2281 if let Some(thread) = self.active_thread_view(cx) {
2282 thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
2283 }
2284 self.serialize(cx);
2285 cx.notify();
2286 }
2287
2288 fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2289 let next = match self.start_thread_in {
2290 StartThreadIn::LocalProject => StartThreadIn::NewWorktree,
2291 StartThreadIn::NewWorktree => StartThreadIn::LocalProject,
2292 };
2293 self.set_start_thread_in(&next, window, cx);
2294 }
2295
2296 fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
2297 use settings::{NewThreadLocation, Settings};
2298 let default = AgentSettings::get_global(cx).new_thread_location;
2299 let start_thread_in = match default {
2300 NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
2301 NewThreadLocation::NewWorktree => {
2302 if self.project_has_git_repository(cx) {
2303 StartThreadIn::NewWorktree
2304 } else {
2305 StartThreadIn::LocalProject
2306 }
2307 }
2308 };
2309 if self.start_thread_in != start_thread_in {
2310 self.start_thread_in = start_thread_in;
2311 self.serialize(cx);
2312 cx.notify();
2313 }
2314 }
2315
2316 pub(crate) fn selected_agent(&self) -> Option<Agent> {
2317 match &self.selected_agent_type {
2318 AgentType::NativeAgent => Some(Agent::NativeAgent),
2319 AgentType::Custom { id } => Some(Agent::Custom { id: id.clone() }),
2320 AgentType::TextThread => None,
2321 }
2322 }
2323
2324 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
2325 if let Some(extension_store) = ExtensionStore::try_global(cx) {
2326 let (manifests, extensions_dir) = {
2327 let store = extension_store.read(cx);
2328 let installed = store.installed_extensions();
2329 let manifests: Vec<_> = installed
2330 .iter()
2331 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
2332 .collect();
2333 let extensions_dir = paths::extensions_dir().join("installed");
2334 (manifests, extensions_dir)
2335 };
2336
2337 self.project.update(cx, |project, cx| {
2338 project.agent_server_store().update(cx, |store, cx| {
2339 let manifest_refs: Vec<_> = manifests
2340 .iter()
2341 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2342 .collect();
2343 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2344 });
2345 });
2346 }
2347 }
2348
2349 pub fn new_agent_thread_with_external_source_prompt(
2350 &mut self,
2351 external_source_prompt: Option<ExternalSourcePrompt>,
2352 window: &mut Window,
2353 cx: &mut Context<Self>,
2354 ) {
2355 self.external_thread(
2356 None,
2357 None,
2358 None,
2359 None,
2360 external_source_prompt.map(AgentInitialContent::from),
2361 true,
2362 window,
2363 cx,
2364 );
2365 }
2366
2367 pub fn new_agent_thread(
2368 &mut self,
2369 agent: AgentType,
2370 window: &mut Window,
2371 cx: &mut Context<Self>,
2372 ) {
2373 self.reset_start_thread_in_to_default(cx);
2374 self.new_agent_thread_inner(agent, true, window, cx);
2375 }
2376
2377 fn new_agent_thread_inner(
2378 &mut self,
2379 agent: AgentType,
2380 focus: bool,
2381 window: &mut Window,
2382 cx: &mut Context<Self>,
2383 ) {
2384 match agent {
2385 AgentType::TextThread => {
2386 window.dispatch_action(NewTextThread.boxed_clone(), cx);
2387 }
2388 AgentType::NativeAgent => self.external_thread(
2389 Some(crate::Agent::NativeAgent),
2390 None,
2391 None,
2392 None,
2393 None,
2394 focus,
2395 window,
2396 cx,
2397 ),
2398 AgentType::Custom { id } => self.external_thread(
2399 Some(crate::Agent::Custom { id }),
2400 None,
2401 None,
2402 None,
2403 None,
2404 focus,
2405 window,
2406 cx,
2407 ),
2408 }
2409 }
2410
2411 pub fn load_agent_thread(
2412 &mut self,
2413 agent: Agent,
2414 session_id: acp::SessionId,
2415 work_dirs: Option<PathList>,
2416 title: Option<SharedString>,
2417 focus: bool,
2418 window: &mut Window,
2419 cx: &mut Context<Self>,
2420 ) {
2421 if let Some(conversation_view) = self.background_threads.remove(&session_id) {
2422 self.set_active_view(
2423 ActiveView::AgentThread { conversation_view },
2424 focus,
2425 window,
2426 cx,
2427 );
2428 return;
2429 }
2430
2431 if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2432 if conversation_view
2433 .read(cx)
2434 .active_thread()
2435 .map(|t| t.read(cx).id.clone())
2436 == Some(session_id.clone())
2437 {
2438 cx.emit(AgentPanelEvent::ActiveViewChanged);
2439 return;
2440 }
2441 }
2442
2443 if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view {
2444 if conversation_view
2445 .read(cx)
2446 .active_thread()
2447 .map(|t| t.read(cx).id.clone())
2448 == Some(session_id.clone())
2449 {
2450 let view = self.previous_view.take().unwrap();
2451 self.set_active_view(view, focus, window, cx);
2452 return;
2453 }
2454 }
2455
2456 self.external_thread(
2457 Some(agent),
2458 Some(session_id),
2459 work_dirs,
2460 title,
2461 None,
2462 focus,
2463 window,
2464 cx,
2465 );
2466 }
2467
2468 pub(crate) fn create_agent_thread(
2469 &mut self,
2470 server: Rc<dyn AgentServer>,
2471 resume_session_id: Option<acp::SessionId>,
2472 work_dirs: Option<PathList>,
2473 title: Option<SharedString>,
2474 initial_content: Option<AgentInitialContent>,
2475 workspace: WeakEntity<Workspace>,
2476 project: Entity<Project>,
2477 ext_agent: Agent,
2478 focus: bool,
2479 window: &mut Window,
2480 cx: &mut Context<Self>,
2481 ) {
2482 let selected_agent = AgentType::from(ext_agent.clone());
2483 if self.selected_agent_type != selected_agent {
2484 self.selected_agent_type = selected_agent;
2485 self.serialize(cx);
2486 }
2487 let thread_store = server
2488 .clone()
2489 .downcast::<agent::NativeAgentServer>()
2490 .is_some()
2491 .then(|| self.thread_store.clone());
2492
2493 let connection_store = self.connection_store.clone();
2494
2495 let conversation_view = cx.new(|cx| {
2496 crate::ConversationView::new(
2497 server,
2498 connection_store,
2499 ext_agent,
2500 resume_session_id,
2501 work_dirs,
2502 title,
2503 initial_content,
2504 workspace.clone(),
2505 project,
2506 thread_store,
2507 self.prompt_store.clone(),
2508 window,
2509 cx,
2510 )
2511 });
2512
2513 cx.observe(&conversation_view, |this, server_view, cx| {
2514 let is_active = this
2515 .active_conversation_view()
2516 .is_some_and(|active| active.entity_id() == server_view.entity_id());
2517 if is_active {
2518 cx.emit(AgentPanelEvent::ActiveViewChanged);
2519 this.serialize(cx);
2520 } else {
2521 cx.emit(AgentPanelEvent::BackgroundThreadChanged);
2522 }
2523 cx.notify();
2524 })
2525 .detach();
2526
2527 self.set_active_view(
2528 ActiveView::AgentThread { conversation_view },
2529 focus,
2530 window,
2531 cx,
2532 );
2533 }
2534
2535 fn active_thread_has_messages(&self, cx: &App) -> bool {
2536 self.active_agent_thread(cx)
2537 .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2538 }
2539
2540 pub fn active_thread_is_draft(&self, cx: &App) -> bool {
2541 self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
2542 }
2543
2544 fn handle_first_send_requested(
2545 &mut self,
2546 thread_view: Entity<ThreadView>,
2547 content: Vec<acp::ContentBlock>,
2548 window: &mut Window,
2549 cx: &mut Context<Self>,
2550 ) {
2551 if self.start_thread_in == StartThreadIn::NewWorktree {
2552 self.handle_worktree_creation_requested(content, window, cx);
2553 } else {
2554 cx.defer_in(window, move |_this, window, cx| {
2555 thread_view.update(cx, |thread_view, cx| {
2556 let editor = thread_view.message_editor.clone();
2557 thread_view.send_impl(editor, window, cx);
2558 });
2559 });
2560 }
2561 }
2562
2563 // TODO: The mapping from workspace root paths to git repositories needs a
2564 // unified approach across the codebase: this method, `sidebar::is_root_repo`,
2565 // thread persistence (which PathList is saved to the database), and thread
2566 // querying (which PathList is used to read threads back). All of these need
2567 // to agree on how repos are resolved for a given workspace, especially in
2568 // multi-root and nested-repo configurations.
2569 /// Partitions the project's visible worktrees into git-backed repositories
2570 /// and plain (non-git) paths. Git repos will have worktrees created for
2571 /// them; non-git paths are carried over to the new workspace as-is.
2572 ///
2573 /// When multiple worktrees map to the same repository, the most specific
2574 /// match wins (deepest work directory path), with a deterministic
2575 /// tie-break on entity id. Each repository appears at most once.
2576 fn classify_worktrees(
2577 &self,
2578 cx: &App,
2579 ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2580 let project = &self.project;
2581 let repositories = project.read(cx).repositories(cx).clone();
2582 let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2583 let mut non_git_paths: Vec<PathBuf> = Vec::new();
2584 let mut seen_repo_ids = std::collections::HashSet::new();
2585
2586 for worktree in project.read(cx).visible_worktrees(cx) {
2587 let wt_path = worktree.read(cx).abs_path();
2588
2589 let matching_repo = repositories
2590 .iter()
2591 .filter_map(|(id, repo)| {
2592 let work_dir = repo.read(cx).work_directory_abs_path.clone();
2593 if wt_path.starts_with(work_dir.as_ref())
2594 || work_dir.starts_with(wt_path.as_ref())
2595 {
2596 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2597 } else {
2598 None
2599 }
2600 })
2601 .max_by(
2602 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2603 left_depth
2604 .cmp(right_depth)
2605 .then_with(|| left_id.cmp(right_id))
2606 },
2607 );
2608
2609 if let Some((id, repo, _)) = matching_repo {
2610 if seen_repo_ids.insert(id) {
2611 git_repos.push(repo);
2612 }
2613 } else {
2614 non_git_paths.push(wt_path.to_path_buf());
2615 }
2616 }
2617
2618 (git_repos, non_git_paths)
2619 }
2620
2621 /// Kicks off an async git-worktree creation for each repository. Returns:
2622 ///
2623 /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2624 /// receiver resolves once the git worktree command finishes.
2625 /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2626 /// later to remap open editor tabs into the new workspace.
2627 fn start_worktree_creations(
2628 git_repos: &[Entity<project::git_store::Repository>],
2629 branch_name: &str,
2630 worktree_directory_setting: &str,
2631 cx: &mut Context<Self>,
2632 ) -> Result<(
2633 Vec<(
2634 Entity<project::git_store::Repository>,
2635 PathBuf,
2636 futures::channel::oneshot::Receiver<Result<()>>,
2637 )>,
2638 Vec<(PathBuf, PathBuf)>,
2639 )> {
2640 let mut creation_infos = Vec::new();
2641 let mut path_remapping = Vec::new();
2642
2643 for repo in git_repos {
2644 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2645 let new_path =
2646 repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
2647 let receiver =
2648 repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
2649 let work_dir = repo.work_directory_abs_path.clone();
2650 anyhow::Ok((work_dir, new_path, receiver))
2651 })?;
2652 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2653 creation_infos.push((repo.clone(), new_path, receiver));
2654 }
2655
2656 Ok((creation_infos, path_remapping))
2657 }
2658
2659 /// Waits for every in-flight worktree creation to complete. If any
2660 /// creation fails, all successfully-created worktrees are rolled back
2661 /// (removed) so the project isn't left in a half-migrated state.
2662 async fn await_and_rollback_on_failure(
2663 creation_infos: Vec<(
2664 Entity<project::git_store::Repository>,
2665 PathBuf,
2666 futures::channel::oneshot::Receiver<Result<()>>,
2667 )>,
2668 cx: &mut AsyncWindowContext,
2669 ) -> Result<Vec<PathBuf>> {
2670 let mut created_paths: Vec<PathBuf> = Vec::new();
2671 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2672 Vec::new();
2673 let mut first_error: Option<anyhow::Error> = None;
2674
2675 for (repo, new_path, receiver) in creation_infos {
2676 match receiver.await {
2677 Ok(Ok(())) => {
2678 created_paths.push(new_path.clone());
2679 repos_and_paths.push((repo, new_path));
2680 }
2681 Ok(Err(err)) => {
2682 if first_error.is_none() {
2683 first_error = Some(err);
2684 }
2685 }
2686 Err(_canceled) => {
2687 if first_error.is_none() {
2688 first_error = Some(anyhow!("Worktree creation was canceled"));
2689 }
2690 }
2691 }
2692 }
2693
2694 let Some(err) = first_error else {
2695 return Ok(created_paths);
2696 };
2697
2698 // Rollback all successfully created worktrees
2699 let mut rollback_receivers = Vec::new();
2700 for (rollback_repo, rollback_path) in &repos_and_paths {
2701 if let Ok(receiver) = cx.update(|_, cx| {
2702 rollback_repo.update(cx, |repo, _cx| {
2703 repo.remove_worktree(rollback_path.clone(), true)
2704 })
2705 }) {
2706 rollback_receivers.push((rollback_path.clone(), receiver));
2707 }
2708 }
2709 let mut rollback_failures: Vec<String> = Vec::new();
2710 for (path, receiver) in rollback_receivers {
2711 match receiver.await {
2712 Ok(Ok(())) => {}
2713 Ok(Err(rollback_err)) => {
2714 log::error!(
2715 "failed to rollback worktree at {}: {rollback_err}",
2716 path.display()
2717 );
2718 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2719 }
2720 Err(rollback_err) => {
2721 log::error!(
2722 "failed to rollback worktree at {}: {rollback_err}",
2723 path.display()
2724 );
2725 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2726 }
2727 }
2728 }
2729 let mut error_message = format!("Failed to create worktree: {err}");
2730 if !rollback_failures.is_empty() {
2731 error_message.push_str("\n\nFailed to clean up: ");
2732 error_message.push_str(&rollback_failures.join(", "));
2733 }
2734 Err(anyhow!(error_message))
2735 }
2736
2737 fn set_worktree_creation_error(
2738 &mut self,
2739 message: SharedString,
2740 window: &mut Window,
2741 cx: &mut Context<Self>,
2742 ) {
2743 self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
2744 if matches!(self.active_view, ActiveView::Uninitialized) {
2745 let selected_agent_type = self.selected_agent_type.clone();
2746 self.new_agent_thread(selected_agent_type, window, cx);
2747 }
2748 cx.notify();
2749 }
2750
2751 fn handle_worktree_creation_requested(
2752 &mut self,
2753 content: Vec<acp::ContentBlock>,
2754 window: &mut Window,
2755 cx: &mut Context<Self>,
2756 ) {
2757 if matches!(
2758 self.worktree_creation_status,
2759 Some(WorktreeCreationStatus::Creating)
2760 ) {
2761 return;
2762 }
2763
2764 self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
2765 cx.notify();
2766
2767 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
2768
2769 if git_repos.is_empty() {
2770 self.set_worktree_creation_error(
2771 "No git repositories found in the project".into(),
2772 window,
2773 cx,
2774 );
2775 return;
2776 }
2777
2778 // Kick off branch listing as early as possible so it can run
2779 // concurrently with the remaining synchronous setup work.
2780 let branch_receivers: Vec<_> = git_repos
2781 .iter()
2782 .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
2783 .collect();
2784
2785 let worktree_directory_setting = ProjectSettings::get_global(cx)
2786 .git
2787 .worktree_directory
2788 .clone();
2789
2790 let active_file_path = self.workspace.upgrade().and_then(|workspace| {
2791 let workspace = workspace.read(cx);
2792 let active_item = workspace.active_item(cx)?;
2793 let project_path = active_item.project_path(cx)?;
2794 workspace
2795 .project()
2796 .read(cx)
2797 .absolute_path(&project_path, cx)
2798 });
2799
2800 let workspace = self.workspace.clone();
2801 let window_handle = window
2802 .window_handle()
2803 .downcast::<workspace::MultiWorkspace>();
2804
2805 let selected_agent = self.selected_agent();
2806
2807 let task = cx.spawn_in(window, async move |this, cx| {
2808 // Await the branch listings we kicked off earlier.
2809 let mut existing_branches = Vec::new();
2810 for result in futures::future::join_all(branch_receivers).await {
2811 match result {
2812 Ok(Ok(branches)) => {
2813 for branch in branches {
2814 existing_branches.push(branch.name().to_string());
2815 }
2816 }
2817 Ok(Err(err)) => {
2818 Err::<(), _>(err).log_err();
2819 }
2820 Err(_) => {}
2821 }
2822 }
2823
2824 let existing_branch_refs: Vec<&str> =
2825 existing_branches.iter().map(|s| s.as_str()).collect();
2826 let mut rng = rand::rng();
2827 let branch_name =
2828 match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
2829 Some(name) => name,
2830 None => {
2831 this.update_in(cx, |this, window, cx| {
2832 this.set_worktree_creation_error(
2833 "Failed to generate a branch name: all typewriter names are taken"
2834 .into(),
2835 window,
2836 cx,
2837 );
2838 })?;
2839 return anyhow::Ok(());
2840 }
2841 };
2842
2843 let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
2844 Self::start_worktree_creations(
2845 &git_repos,
2846 &branch_name,
2847 &worktree_directory_setting,
2848 cx,
2849 )
2850 }) {
2851 Ok(Ok(result)) => result,
2852 Ok(Err(err)) | Err(err) => {
2853 this.update_in(cx, |this, window, cx| {
2854 this.set_worktree_creation_error(
2855 format!("Failed to validate worktree directory: {err}").into(),
2856 window,
2857 cx,
2858 );
2859 })
2860 .log_err();
2861 return anyhow::Ok(());
2862 }
2863 };
2864
2865 let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
2866 {
2867 Ok(paths) => paths,
2868 Err(err) => {
2869 this.update_in(cx, |this, window, cx| {
2870 this.set_worktree_creation_error(format!("{err}").into(), window, cx);
2871 })?;
2872 return anyhow::Ok(());
2873 }
2874 };
2875
2876 let mut all_paths = created_paths;
2877 let has_non_git = !non_git_paths.is_empty();
2878 all_paths.extend(non_git_paths.iter().cloned());
2879
2880 let app_state = match workspace.upgrade() {
2881 Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
2882 None => {
2883 this.update_in(cx, |this, window, cx| {
2884 this.set_worktree_creation_error(
2885 "Workspace no longer available".into(),
2886 window,
2887 cx,
2888 );
2889 })?;
2890 return anyhow::Ok(());
2891 }
2892 };
2893
2894 let this_for_error = this.clone();
2895 if let Err(err) = Self::setup_new_workspace(
2896 this,
2897 all_paths,
2898 app_state,
2899 window_handle,
2900 active_file_path,
2901 path_remapping,
2902 non_git_paths,
2903 has_non_git,
2904 content,
2905 selected_agent,
2906 cx,
2907 )
2908 .await
2909 {
2910 this_for_error
2911 .update_in(cx, |this, window, cx| {
2912 this.set_worktree_creation_error(
2913 format!("Failed to set up workspace: {err}").into(),
2914 window,
2915 cx,
2916 );
2917 })
2918 .log_err();
2919 }
2920 anyhow::Ok(())
2921 });
2922
2923 self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move {
2924 task.await.log_err();
2925 }));
2926 }
2927
2928 async fn setup_new_workspace(
2929 this: WeakEntity<Self>,
2930 all_paths: Vec<PathBuf>,
2931 app_state: Arc<workspace::AppState>,
2932 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
2933 active_file_path: Option<PathBuf>,
2934 path_remapping: Vec<(PathBuf, PathBuf)>,
2935 non_git_paths: Vec<PathBuf>,
2936 has_non_git: bool,
2937 content: Vec<acp::ContentBlock>,
2938 selected_agent: Option<Agent>,
2939 cx: &mut AsyncWindowContext,
2940 ) -> Result<()> {
2941 let OpenResult {
2942 window: new_window_handle,
2943 workspace: new_workspace,
2944 ..
2945 } = cx
2946 .update(|_window, cx| {
2947 Workspace::new_local(all_paths, app_state, window_handle, None, None, false, cx)
2948 })?
2949 .await?;
2950
2951 let panels_task = new_window_handle.update(cx, |_, _, cx| {
2952 new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task())
2953 })?;
2954 if let Some(task) = panels_task {
2955 task.await.log_err();
2956 }
2957
2958 let initial_content = AgentInitialContent::ContentBlock {
2959 blocks: content,
2960 auto_submit: true,
2961 };
2962
2963 new_window_handle.update(cx, |_multi_workspace, window, cx| {
2964 new_workspace.update(cx, |workspace, cx| {
2965 if has_non_git {
2966 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
2967 workspace.show_toast(
2968 workspace::Toast::new(
2969 toast_id,
2970 "Some project folders are not git repositories. \
2971 They were included as-is without creating a worktree.",
2972 ),
2973 cx,
2974 );
2975 }
2976
2977 // If we had an active buffer, remap its path and reopen it.
2978 let should_zoom_agent_panel = active_file_path.is_none();
2979
2980 let remapped_active_path = active_file_path.and_then(|original_path| {
2981 let best_match = path_remapping
2982 .iter()
2983 .filter_map(|(old_root, new_root)| {
2984 original_path.strip_prefix(old_root).ok().map(|relative| {
2985 (old_root.components().count(), new_root.join(relative))
2986 })
2987 })
2988 .max_by_key(|(depth, _)| *depth);
2989
2990 if let Some((_, remapped_path)) = best_match {
2991 return Some(remapped_path);
2992 }
2993
2994 for non_git in &non_git_paths {
2995 if original_path.starts_with(non_git) {
2996 return Some(original_path);
2997 }
2998 }
2999 None
3000 });
3001
3002 if !should_zoom_agent_panel && remapped_active_path.is_none() {
3003 log::warn!(
3004 "Active file could not be remapped to the new worktree; it will not be reopened"
3005 );
3006 }
3007
3008 if let Some(path) = remapped_active_path {
3009 let open_task = workspace.open_paths(
3010 vec![path],
3011 workspace::OpenOptions::default(),
3012 None,
3013 window,
3014 cx,
3015 );
3016 cx.spawn(async move |_, _| -> anyhow::Result<()> {
3017 for item in open_task.await.into_iter().flatten() {
3018 item?;
3019 }
3020 Ok(())
3021 })
3022 .detach_and_log_err(cx);
3023 }
3024
3025 workspace.focus_panel::<AgentPanel>(window, cx);
3026
3027 // If no active buffer was open, zoom the agent panel
3028 // (equivalent to cmd-esc fullscreen behavior).
3029 // This must happen after focus_panel, which activates
3030 // and opens the panel in the dock.
3031 if should_zoom_agent_panel {
3032 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3033 panel.update(cx, |_panel, cx| {
3034 cx.emit(PanelEvent::ZoomIn);
3035 });
3036 }
3037 }
3038 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3039 panel.update(cx, |panel, cx| {
3040 panel.external_thread(
3041 selected_agent,
3042 None,
3043 None,
3044 None,
3045 Some(initial_content),
3046 true,
3047 window,
3048 cx,
3049 );
3050 });
3051 }
3052 });
3053 })?;
3054
3055 new_window_handle.update(cx, |multi_workspace, _window, cx| {
3056 multi_workspace.activate(new_workspace.clone(), cx);
3057 })?;
3058
3059 this.update_in(cx, |this, window, cx| {
3060 this.worktree_creation_status = None;
3061
3062 if let Some(thread_view) = this.active_thread_view(cx) {
3063 thread_view.update(cx, |thread_view, cx| {
3064 thread_view
3065 .message_editor
3066 .update(cx, |editor, cx| editor.clear(window, cx));
3067 });
3068 }
3069
3070 cx.notify();
3071 })?;
3072
3073 anyhow::Ok(())
3074 }
3075}
3076
3077impl Focusable for AgentPanel {
3078 fn focus_handle(&self, cx: &App) -> FocusHandle {
3079 match &self.active_view {
3080 ActiveView::Uninitialized => self.focus_handle.clone(),
3081 ActiveView::AgentThread {
3082 conversation_view, ..
3083 } => conversation_view.focus_handle(cx),
3084 ActiveView::History { history: kind } => match kind {
3085 History::AgentThreads { view } => view.read(cx).focus_handle(cx),
3086 History::TextThreads => self.text_thread_history.focus_handle(cx),
3087 },
3088 ActiveView::TextThread {
3089 text_thread_editor, ..
3090 } => text_thread_editor.focus_handle(cx),
3091 ActiveView::Configuration => {
3092 if let Some(configuration) = self.configuration.as_ref() {
3093 configuration.focus_handle(cx)
3094 } else {
3095 self.focus_handle.clone()
3096 }
3097 }
3098 }
3099 }
3100}
3101
3102fn agent_panel_dock_position(cx: &App) -> DockPosition {
3103 AgentSettings::get_global(cx).dock.into()
3104}
3105
3106pub enum AgentPanelEvent {
3107 ActiveViewChanged,
3108 ThreadFocused,
3109 BackgroundThreadChanged,
3110}
3111
3112impl EventEmitter<PanelEvent> for AgentPanel {}
3113impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3114
3115impl Panel for AgentPanel {
3116 fn persistent_name() -> &'static str {
3117 "AgentPanel"
3118 }
3119
3120 fn panel_key() -> &'static str {
3121 AGENT_PANEL_KEY
3122 }
3123
3124 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3125 agent_panel_dock_position(cx)
3126 }
3127
3128 fn position_is_valid(&self, position: DockPosition) -> bool {
3129 position != DockPosition::Bottom
3130 }
3131
3132 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
3133 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3134 settings
3135 .agent
3136 .get_or_insert_default()
3137 .set_dock(position.into());
3138 });
3139 }
3140
3141 fn size(&self, window: &Window, cx: &App) -> Pixels {
3142 let settings = AgentSettings::get_global(cx);
3143 match self.position(window, cx) {
3144 DockPosition::Left | DockPosition::Right => {
3145 self.width.unwrap_or(settings.default_width)
3146 }
3147 DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
3148 }
3149 }
3150
3151 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
3152 match self.position(window, cx) {
3153 DockPosition::Left | DockPosition::Right => self.width = size,
3154 DockPosition::Bottom => self.height = size,
3155 }
3156 self.serialize(cx);
3157 cx.notify();
3158 }
3159
3160 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
3161 if active
3162 && matches!(self.active_view, ActiveView::Uninitialized)
3163 && !matches!(
3164 self.worktree_creation_status,
3165 Some(WorktreeCreationStatus::Creating)
3166 )
3167 {
3168 let selected_agent_type = self.selected_agent_type.clone();
3169 self.new_agent_thread_inner(selected_agent_type, false, window, cx);
3170 }
3171 }
3172
3173 fn remote_id() -> Option<proto::PanelId> {
3174 Some(proto::PanelId::AssistantPanel)
3175 }
3176
3177 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3178 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3179 }
3180
3181 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3182 Some("Agent Panel")
3183 }
3184
3185 fn toggle_action(&self) -> Box<dyn Action> {
3186 Box::new(ToggleFocus)
3187 }
3188
3189 fn activation_priority(&self) -> u32 {
3190 3
3191 }
3192
3193 fn enabled(&self, cx: &App) -> bool {
3194 AgentSettings::get_global(cx).enabled(cx)
3195 }
3196
3197 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3198 self.zoomed
3199 }
3200
3201 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3202 self.zoomed = zoomed;
3203 cx.notify();
3204 }
3205}
3206
3207impl AgentPanel {
3208 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3209 const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
3210
3211 let content = match &self.active_view {
3212 ActiveView::AgentThread { conversation_view } => {
3213 let server_view_ref = conversation_view.read(cx);
3214 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3215 && server_view_ref.root_thread(cx).map_or(false, |tv| {
3216 tv.read(cx).thread.read(cx).has_provisional_title()
3217 });
3218
3219 if let Some(title_editor) = server_view_ref
3220 .root_thread(cx)
3221 .map(|r| r.read(cx).title_editor.clone())
3222 {
3223 if is_generating_title {
3224 Label::new("New Thread…")
3225 .color(Color::Muted)
3226 .truncate()
3227 .with_animation(
3228 "generating_title",
3229 Animation::new(Duration::from_secs(2))
3230 .repeat()
3231 .with_easing(pulsating_between(0.4, 0.8)),
3232 |label, delta| label.alpha(delta),
3233 )
3234 .into_any_element()
3235 } else {
3236 div()
3237 .w_full()
3238 .on_action({
3239 let conversation_view = conversation_view.downgrade();
3240 move |_: &menu::Confirm, window, cx| {
3241 if let Some(conversation_view) = conversation_view.upgrade() {
3242 conversation_view.focus_handle(cx).focus(window, cx);
3243 }
3244 }
3245 })
3246 .on_action({
3247 let conversation_view = conversation_view.downgrade();
3248 move |_: &editor::actions::Cancel, window, cx| {
3249 if let Some(conversation_view) = conversation_view.upgrade() {
3250 conversation_view.focus_handle(cx).focus(window, cx);
3251 }
3252 }
3253 })
3254 .child(title_editor)
3255 .into_any_element()
3256 }
3257 } else {
3258 Label::new(conversation_view.read(cx).title(cx))
3259 .color(Color::Muted)
3260 .truncate()
3261 .into_any_element()
3262 }
3263 }
3264 ActiveView::TextThread {
3265 title_editor,
3266 text_thread_editor,
3267 ..
3268 } => {
3269 let summary = text_thread_editor.read(cx).text_thread().read(cx).summary();
3270
3271 match summary {
3272 TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT)
3273 .color(Color::Muted)
3274 .truncate()
3275 .into_any_element(),
3276 TextThreadSummary::Content(summary) => {
3277 if summary.done {
3278 div()
3279 .w_full()
3280 .child(title_editor.clone())
3281 .into_any_element()
3282 } else {
3283 Label::new(LOADING_SUMMARY_PLACEHOLDER)
3284 .truncate()
3285 .color(Color::Muted)
3286 .with_animation(
3287 "generating_title",
3288 Animation::new(Duration::from_secs(2))
3289 .repeat()
3290 .with_easing(pulsating_between(0.4, 0.8)),
3291 |label, delta| label.alpha(delta),
3292 )
3293 .into_any_element()
3294 }
3295 }
3296 TextThreadSummary::Error => h_flex()
3297 .w_full()
3298 .child(title_editor.clone())
3299 .child(
3300 IconButton::new("retry-summary-generation", IconName::RotateCcw)
3301 .icon_size(IconSize::Small)
3302 .on_click({
3303 let text_thread_editor = text_thread_editor.clone();
3304 move |_, _window, cx| {
3305 text_thread_editor.update(cx, |text_thread_editor, cx| {
3306 text_thread_editor.regenerate_summary(cx);
3307 });
3308 }
3309 })
3310 .tooltip(move |_window, cx| {
3311 cx.new(|_| {
3312 Tooltip::new("Failed to generate title")
3313 .meta("Click to try again")
3314 })
3315 .into()
3316 }),
3317 )
3318 .into_any_element(),
3319 }
3320 }
3321 ActiveView::History { history: kind } => {
3322 let title = match kind {
3323 History::AgentThreads { .. } => "History",
3324 History::TextThreads => "Text Thread History",
3325 };
3326 Label::new(title).truncate().into_any_element()
3327 }
3328 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
3329 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3330 };
3331
3332 h_flex()
3333 .key_context("TitleEditor")
3334 .id("TitleEditor")
3335 .flex_grow()
3336 .w_full()
3337 .max_w_full()
3338 .overflow_x_scroll()
3339 .child(content)
3340 .into_any()
3341 }
3342
3343 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3344 conversation_view.update(cx, |conversation_view, cx| {
3345 if let Some(thread) = conversation_view.as_native_thread(cx) {
3346 thread.update(cx, |thread, cx| {
3347 thread.generate_title(cx);
3348 });
3349 }
3350 });
3351 }
3352
3353 fn handle_regenerate_text_thread_title(
3354 text_thread_editor: Entity<TextThreadEditor>,
3355 cx: &mut App,
3356 ) {
3357 text_thread_editor.update(cx, |text_thread_editor, cx| {
3358 text_thread_editor.regenerate_summary(cx);
3359 });
3360 }
3361
3362 fn render_panel_options_menu(
3363 &self,
3364 window: &mut Window,
3365 cx: &mut Context<Self>,
3366 ) -> impl IntoElement {
3367 let focus_handle = self.focus_handle(cx);
3368
3369 let full_screen_label = if self.is_zoomed(window, cx) {
3370 "Disable Full Screen"
3371 } else {
3372 "Enable Full Screen"
3373 };
3374
3375 let text_thread_view = match &self.active_view {
3376 ActiveView::TextThread {
3377 text_thread_editor, ..
3378 } => Some(text_thread_editor.clone()),
3379 _ => None,
3380 };
3381 let text_thread_with_messages = match &self.active_view {
3382 ActiveView::TextThread {
3383 text_thread_editor, ..
3384 } => text_thread_editor
3385 .read(cx)
3386 .text_thread()
3387 .read(cx)
3388 .messages(cx)
3389 .any(|message| message.role == language_model::Role::Assistant),
3390 _ => false,
3391 };
3392
3393 let conversation_view = match &self.active_view {
3394 ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3395 _ => None,
3396 };
3397 let thread_with_messages = match &self.active_view {
3398 ActiveView::AgentThread { conversation_view } => {
3399 conversation_view.read(cx).has_user_submitted_prompt(cx)
3400 }
3401 _ => false,
3402 };
3403 let has_auth_methods = match &self.active_view {
3404 ActiveView::AgentThread { conversation_view } => {
3405 conversation_view.read(cx).has_auth_methods()
3406 }
3407 _ => false,
3408 };
3409
3410 PopoverMenu::new("agent-options-menu")
3411 .trigger_with_tooltip(
3412 IconButton::new("agent-options-menu", IconName::Ellipsis)
3413 .icon_size(IconSize::Small),
3414 {
3415 let focus_handle = focus_handle.clone();
3416 move |_window, cx| {
3417 Tooltip::for_action_in(
3418 "Toggle Agent Menu",
3419 &ToggleOptionsMenu,
3420 &focus_handle,
3421 cx,
3422 )
3423 }
3424 },
3425 )
3426 .anchor(Corner::TopRight)
3427 .with_handle(self.agent_panel_menu_handle.clone())
3428 .menu({
3429 move |window, cx| {
3430 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3431 menu = menu.context(focus_handle.clone());
3432
3433 if thread_with_messages | text_thread_with_messages {
3434 menu = menu.header("Current Thread");
3435
3436 if let Some(text_thread_view) = text_thread_view.as_ref() {
3437 menu = menu
3438 .entry("Regenerate Thread Title", None, {
3439 let text_thread_view = text_thread_view.clone();
3440 move |_, cx| {
3441 Self::handle_regenerate_text_thread_title(
3442 text_thread_view.clone(),
3443 cx,
3444 );
3445 }
3446 })
3447 .separator();
3448 }
3449
3450 if let Some(conversation_view) = conversation_view.as_ref() {
3451 menu = menu
3452 .entry("Regenerate Thread Title", None, {
3453 let conversation_view = conversation_view.clone();
3454 move |_, cx| {
3455 Self::handle_regenerate_thread_title(
3456 conversation_view.clone(),
3457 cx,
3458 );
3459 }
3460 })
3461 .separator();
3462 }
3463 }
3464
3465 menu = menu
3466 .header("MCP Servers")
3467 .action(
3468 "View Server Extensions",
3469 Box::new(zed_actions::Extensions {
3470 category_filter: Some(
3471 zed_actions::ExtensionCategoryFilter::ContextServers,
3472 ),
3473 id: None,
3474 }),
3475 )
3476 .action("Add Custom Server…", Box::new(AddContextServer))
3477 .separator()
3478 .action("Rules", Box::new(OpenRulesLibrary::default()))
3479 .action("Profiles", Box::new(ManageProfiles::default()))
3480 .action("Settings", Box::new(OpenSettings))
3481 .separator()
3482 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar))
3483 .action(full_screen_label, Box::new(ToggleZoom));
3484
3485 if has_auth_methods {
3486 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3487 }
3488
3489 menu
3490 }))
3491 }
3492 })
3493 }
3494
3495 fn render_recent_entries_menu(
3496 &self,
3497 icon: IconName,
3498 corner: Corner,
3499 cx: &mut Context<Self>,
3500 ) -> impl IntoElement {
3501 let focus_handle = self.focus_handle(cx);
3502
3503 PopoverMenu::new("agent-nav-menu")
3504 .trigger_with_tooltip(
3505 IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
3506 {
3507 move |_window, cx| {
3508 Tooltip::for_action_in(
3509 "Toggle Recently Updated Threads",
3510 &ToggleNavigationMenu,
3511 &focus_handle,
3512 cx,
3513 )
3514 }
3515 },
3516 )
3517 .anchor(corner)
3518 .with_handle(self.agent_navigation_menu_handle.clone())
3519 .menu({
3520 let menu = self.agent_navigation_menu.clone();
3521 move |window, cx| {
3522 telemetry::event!("View Thread History Clicked");
3523
3524 if let Some(menu) = menu.as_ref() {
3525 menu.update(cx, |_, cx| {
3526 cx.defer_in(window, |menu, window, cx| {
3527 menu.rebuild(window, cx);
3528 });
3529 })
3530 }
3531 menu.clone()
3532 }
3533 })
3534 }
3535
3536 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3537 let focus_handle = self.focus_handle(cx);
3538
3539 IconButton::new("go-back", IconName::ArrowLeft)
3540 .icon_size(IconSize::Small)
3541 .on_click(cx.listener(|this, _, window, cx| {
3542 this.go_back(&workspace::GoBack, window, cx);
3543 }))
3544 .tooltip({
3545 move |_window, cx| {
3546 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3547 }
3548 })
3549 }
3550
3551 fn project_has_git_repository(&self, cx: &App) -> bool {
3552 !self.project.read(cx).repositories(cx).is_empty()
3553 }
3554
3555 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3556 use settings::{NewThreadLocation, Settings};
3557
3558 let focus_handle = self.focus_handle(cx);
3559 let has_git_repo = self.project_has_git_repository(cx);
3560 let is_via_collab = self.project.read(cx).is_via_collab();
3561 let fs = self.fs.clone();
3562
3563 let is_creating = matches!(
3564 self.worktree_creation_status,
3565 Some(WorktreeCreationStatus::Creating)
3566 );
3567
3568 let current_target = self.start_thread_in;
3569 let trigger_label = self.start_thread_in.label();
3570
3571 let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
3572 let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
3573 let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
3574
3575 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3576 IconName::ChevronUp
3577 } else {
3578 IconName::ChevronDown
3579 };
3580
3581 let trigger_button = Button::new("thread-target-trigger", trigger_label)
3582 .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
3583 .disabled(is_creating);
3584
3585 let dock_position = AgentSettings::get_global(cx).dock;
3586 let documentation_side = match dock_position {
3587 settings::DockPosition::Left => DocumentationSide::Right,
3588 settings::DockPosition::Bottom | settings::DockPosition::Right => {
3589 DocumentationSide::Left
3590 }
3591 };
3592
3593 PopoverMenu::new("thread-target-selector")
3594 .trigger_with_tooltip(trigger_button, {
3595 move |_window, cx| {
3596 Tooltip::for_action_in(
3597 "Start Thread In…",
3598 &CycleStartThreadIn,
3599 &focus_handle,
3600 cx,
3601 )
3602 }
3603 })
3604 .menu(move |window, cx| {
3605 let is_local_selected = current_target == StartThreadIn::LocalProject;
3606 let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
3607 let fs = fs.clone();
3608
3609 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
3610 let new_worktree_disabled = !has_git_repo || is_via_collab;
3611
3612 menu.header("Start Thread In…")
3613 .item(
3614 ContextMenuEntry::new("Current Worktree")
3615 .toggleable(IconPosition::End, is_local_selected)
3616 .documentation_aside(documentation_side, move |_| {
3617 HoldForDefault::new(is_local_default)
3618 .more_content(false)
3619 .into_any_element()
3620 })
3621 .handler({
3622 let fs = fs.clone();
3623 move |window, cx| {
3624 if window.modifiers().secondary() {
3625 update_settings_file(fs.clone(), cx, |settings, _| {
3626 settings
3627 .agent
3628 .get_or_insert_default()
3629 .set_new_thread_location(
3630 NewThreadLocation::LocalProject,
3631 );
3632 });
3633 }
3634 window.dispatch_action(
3635 Box::new(StartThreadIn::LocalProject),
3636 cx,
3637 );
3638 }
3639 }),
3640 )
3641 .item({
3642 let entry = ContextMenuEntry::new("New Git Worktree")
3643 .toggleable(IconPosition::End, is_new_worktree_selected)
3644 .disabled(new_worktree_disabled)
3645 .handler({
3646 let fs = fs.clone();
3647 move |window, cx| {
3648 if window.modifiers().secondary() {
3649 update_settings_file(fs.clone(), cx, |settings, _| {
3650 settings
3651 .agent
3652 .get_or_insert_default()
3653 .set_new_thread_location(
3654 NewThreadLocation::NewWorktree,
3655 );
3656 });
3657 }
3658 window.dispatch_action(
3659 Box::new(StartThreadIn::NewWorktree),
3660 cx,
3661 );
3662 }
3663 });
3664
3665 if new_worktree_disabled {
3666 entry.documentation_aside(documentation_side, move |_| {
3667 let reason = if !has_git_repo {
3668 "No git repository found in this project."
3669 } else {
3670 "Not available for remote/collab projects yet."
3671 };
3672 Label::new(reason)
3673 .color(Color::Muted)
3674 .size(LabelSize::Small)
3675 .into_any_element()
3676 })
3677 } else {
3678 entry.documentation_aside(documentation_side, move |_| {
3679 HoldForDefault::new(is_new_worktree_default)
3680 .more_content(false)
3681 .into_any_element()
3682 })
3683 }
3684 })
3685 }))
3686 })
3687 .with_handle(self.start_thread_in_menu_handle.clone())
3688 .anchor(Corner::TopLeft)
3689 .offset(gpui::Point {
3690 x: px(1.0),
3691 y: px(1.0),
3692 })
3693 }
3694
3695 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3696 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3697 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3698 let focus_handle = self.focus_handle(cx);
3699
3700 let (selected_agent_custom_icon, selected_agent_label) =
3701 if let AgentType::Custom { id, .. } = &self.selected_agent_type {
3702 let store = agent_server_store.read(cx);
3703 let icon = store.agent_icon(&id);
3704
3705 let label = store
3706 .agent_display_name(&id)
3707 .unwrap_or_else(|| self.selected_agent_type.label());
3708 (icon, label)
3709 } else {
3710 (None, self.selected_agent_type.label())
3711 };
3712
3713 let active_thread = match &self.active_view {
3714 ActiveView::AgentThread { conversation_view } => {
3715 conversation_view.read(cx).as_native_thread(cx)
3716 }
3717 ActiveView::Uninitialized
3718 | ActiveView::TextThread { .. }
3719 | ActiveView::History { .. }
3720 | ActiveView::Configuration => None,
3721 };
3722
3723 let new_thread_menu_builder: Rc<
3724 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3725 > = {
3726 let selected_agent = self.selected_agent_type.clone();
3727 let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type;
3728
3729 let workspace = self.workspace.clone();
3730 let is_via_collab = workspace
3731 .update(cx, |workspace, cx| {
3732 workspace.project().read(cx).is_via_collab()
3733 })
3734 .unwrap_or_default();
3735
3736 let focus_handle = focus_handle.clone();
3737 let agent_server_store = agent_server_store;
3738
3739 Rc::new(move |window, cx| {
3740 telemetry::event!("New Thread Clicked");
3741
3742 let active_thread = active_thread.clone();
3743 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3744 menu.context(focus_handle.clone())
3745 .when_some(active_thread, |this, active_thread| {
3746 let thread = active_thread.read(cx);
3747
3748 if !thread.is_empty() {
3749 let session_id = thread.id().clone();
3750 this.item(
3751 ContextMenuEntry::new("New From Summary")
3752 .icon(IconName::ThreadFromSummary)
3753 .icon_color(Color::Muted)
3754 .handler(move |window, cx| {
3755 window.dispatch_action(
3756 Box::new(NewNativeAgentThreadFromSummary {
3757 from_session_id: session_id.clone(),
3758 }),
3759 cx,
3760 );
3761 }),
3762 )
3763 } else {
3764 this
3765 }
3766 })
3767 .item(
3768 ContextMenuEntry::new("Zed Agent")
3769 .when(
3770 is_agent_selected(AgentType::NativeAgent)
3771 | is_agent_selected(AgentType::TextThread),
3772 |this| {
3773 this.action(Box::new(NewExternalAgentThread {
3774 agent: None,
3775 }))
3776 },
3777 )
3778 .icon(IconName::ZedAgent)
3779 .icon_color(Color::Muted)
3780 .handler({
3781 let workspace = workspace.clone();
3782 move |window, cx| {
3783 if let Some(workspace) = workspace.upgrade() {
3784 workspace.update(cx, |workspace, cx| {
3785 if let Some(panel) =
3786 workspace.panel::<AgentPanel>(cx)
3787 {
3788 panel.update(cx, |panel, cx| {
3789 panel.new_agent_thread(
3790 AgentType::NativeAgent,
3791 window,
3792 cx,
3793 );
3794 });
3795 }
3796 });
3797 }
3798 }
3799 }),
3800 )
3801 .item(
3802 ContextMenuEntry::new("Text Thread")
3803 .action(NewTextThread.boxed_clone())
3804 .icon(IconName::TextThread)
3805 .icon_color(Color::Muted)
3806 .handler({
3807 let workspace = workspace.clone();
3808 move |window, cx| {
3809 if let Some(workspace) = workspace.upgrade() {
3810 workspace.update(cx, |workspace, cx| {
3811 if let Some(panel) =
3812 workspace.panel::<AgentPanel>(cx)
3813 {
3814 panel.update(cx, |panel, cx| {
3815 panel.new_agent_thread(
3816 AgentType::TextThread,
3817 window,
3818 cx,
3819 );
3820 });
3821 }
3822 });
3823 }
3824 }
3825 }),
3826 )
3827 .separator()
3828 .header("External Agents")
3829 .map(|mut menu| {
3830 let agent_server_store = agent_server_store.read(cx);
3831 let registry_store = project::AgentRegistryStore::try_global(cx);
3832 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
3833
3834 struct AgentMenuItem {
3835 id: AgentId,
3836 display_name: SharedString,
3837 }
3838
3839 let agent_items = agent_server_store
3840 .external_agents()
3841 .map(|agent_id| {
3842 let display_name = agent_server_store
3843 .agent_display_name(agent_id)
3844 .or_else(|| {
3845 registry_store_ref
3846 .as_ref()
3847 .and_then(|store| store.agent(agent_id))
3848 .map(|a| a.name().clone())
3849 })
3850 .unwrap_or_else(|| agent_id.0.clone());
3851 AgentMenuItem {
3852 id: agent_id.clone(),
3853 display_name,
3854 }
3855 })
3856 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3857 .collect::<Vec<_>>();
3858
3859 for item in &agent_items {
3860 let mut entry = ContextMenuEntry::new(item.display_name.clone());
3861
3862 let icon_path =
3863 agent_server_store.agent_icon(&item.id).or_else(|| {
3864 registry_store_ref
3865 .as_ref()
3866 .and_then(|store| store.agent(&item.id))
3867 .and_then(|a| a.icon_path().cloned())
3868 });
3869
3870 if let Some(icon_path) = icon_path {
3871 entry = entry.custom_icon_svg(icon_path);
3872 } else {
3873 entry = entry.icon(IconName::Sparkle);
3874 }
3875
3876 entry = entry
3877 .when(
3878 is_agent_selected(AgentType::Custom {
3879 id: item.id.clone(),
3880 }),
3881 |this| {
3882 this.action(Box::new(NewExternalAgentThread {
3883 agent: None,
3884 }))
3885 },
3886 )
3887 .icon_color(Color::Muted)
3888 .disabled(is_via_collab)
3889 .handler({
3890 let workspace = workspace.clone();
3891 let agent_id = item.id.clone();
3892 move |window, cx| {
3893 if let Some(workspace) = workspace.upgrade() {
3894 workspace.update(cx, |workspace, cx| {
3895 if let Some(panel) =
3896 workspace.panel::<AgentPanel>(cx)
3897 {
3898 panel.update(cx, |panel, cx| {
3899 panel.new_agent_thread(
3900 AgentType::Custom {
3901 id: agent_id.clone(),
3902 },
3903 window,
3904 cx,
3905 );
3906 });
3907 }
3908 });
3909 }
3910 }
3911 });
3912
3913 menu = menu.item(entry);
3914 }
3915
3916 menu
3917 })
3918 .separator()
3919 .item(
3920 ContextMenuEntry::new("Add More Agents")
3921 .icon(IconName::Plus)
3922 .icon_color(Color::Muted)
3923 .handler({
3924 move |window, cx| {
3925 window
3926 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
3927 }
3928 }),
3929 )
3930 }))
3931 })
3932 };
3933
3934 let is_thread_loading = self
3935 .active_conversation_view()
3936 .map(|thread| thread.read(cx).is_loading())
3937 .unwrap_or(false);
3938
3939 let has_custom_icon = selected_agent_custom_icon.is_some();
3940 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
3941 let selected_agent_builtin_icon = self.selected_agent_type.icon();
3942 let selected_agent_label_for_tooltip = selected_agent_label.clone();
3943
3944 let selected_agent = div()
3945 .id("selected_agent_icon")
3946 .when_some(selected_agent_custom_icon, |this, icon_path| {
3947 this.px_1()
3948 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
3949 })
3950 .when(!has_custom_icon, |this| {
3951 this.when_some(self.selected_agent_type.icon(), |this, icon| {
3952 this.px_1().child(Icon::new(icon).color(Color::Muted))
3953 })
3954 })
3955 .tooltip(move |_, cx| {
3956 Tooltip::with_meta(
3957 selected_agent_label_for_tooltip.clone(),
3958 None,
3959 "Selected Agent",
3960 cx,
3961 )
3962 });
3963
3964 let selected_agent = if is_thread_loading {
3965 selected_agent
3966 .with_animation(
3967 "pulsating-icon",
3968 Animation::new(Duration::from_secs(1))
3969 .repeat()
3970 .with_easing(pulsating_between(0.2, 0.6)),
3971 |icon, delta| icon.opacity(delta),
3972 )
3973 .into_any_element()
3974 } else {
3975 selected_agent.into_any_element()
3976 };
3977
3978 let show_history_menu = self.has_history_for_selected_agent(cx);
3979 let has_v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
3980 let is_empty_state = !self.active_thread_has_messages(cx);
3981
3982 let is_in_history_or_config = matches!(
3983 &self.active_view,
3984 ActiveView::History { .. } | ActiveView::Configuration
3985 );
3986
3987 let is_text_thread = matches!(&self.active_view, ActiveView::TextThread { .. });
3988
3989 let use_v2_empty_toolbar =
3990 has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread;
3991
3992 let base_container = h_flex()
3993 .id("agent-panel-toolbar")
3994 .h(Tab::container_height(cx))
3995 .max_w_full()
3996 .flex_none()
3997 .justify_between()
3998 .gap_2()
3999 .bg(cx.theme().colors().tab_bar_background)
4000 .border_b_1()
4001 .border_color(cx.theme().colors().border);
4002
4003 if use_v2_empty_toolbar {
4004 let (chevron_icon, icon_color, label_color) =
4005 if self.new_thread_menu_handle.is_deployed() {
4006 (IconName::ChevronUp, Color::Accent, Color::Accent)
4007 } else {
4008 (IconName::ChevronDown, Color::Muted, Color::Default)
4009 };
4010
4011 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
4012 Icon::from_external_svg(icon_path)
4013 .size(IconSize::Small)
4014 .color(icon_color)
4015 } else {
4016 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
4017 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
4018 };
4019
4020 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4021 .start_icon(agent_icon)
4022 .color(label_color)
4023 .end_icon(
4024 Icon::new(chevron_icon)
4025 .color(icon_color)
4026 .size(IconSize::XSmall),
4027 );
4028
4029 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4030 .trigger_with_tooltip(agent_selector_button, {
4031 move |_window, cx| {
4032 Tooltip::for_action_in(
4033 "New Thread\u{2026}",
4034 &ToggleNewThreadMenu,
4035 &focus_handle,
4036 cx,
4037 )
4038 }
4039 })
4040 .menu({
4041 let builder = new_thread_menu_builder.clone();
4042 move |window, cx| builder(window, cx)
4043 })
4044 .with_handle(self.new_thread_menu_handle.clone())
4045 .anchor(Corner::TopLeft)
4046 .offset(gpui::Point {
4047 x: px(1.0),
4048 y: px(1.0),
4049 });
4050
4051 base_container
4052 .child(
4053 h_flex()
4054 .size_full()
4055 .gap(DynamicSpacing::Base04.rems(cx))
4056 .pl(DynamicSpacing::Base04.rems(cx))
4057 .child(agent_selector_menu)
4058 .when(
4059 has_visible_worktrees && self.project_has_git_repository(cx),
4060 |this| this.child(self.render_start_thread_in_selector(cx)),
4061 ),
4062 )
4063 .child(
4064 h_flex()
4065 .h_full()
4066 .flex_none()
4067 .gap_1()
4068 .pl_1()
4069 .pr_1()
4070 .when(show_history_menu && !has_v2_flag, |this| {
4071 this.child(self.render_recent_entries_menu(
4072 IconName::MenuAltTemp,
4073 Corner::TopRight,
4074 cx,
4075 ))
4076 })
4077 .child(self.render_panel_options_menu(window, cx)),
4078 )
4079 .into_any_element()
4080 } else {
4081 let new_thread_menu = PopoverMenu::new("new_thread_menu")
4082 .trigger_with_tooltip(
4083 IconButton::new("new_thread_menu_btn", IconName::Plus)
4084 .icon_size(IconSize::Small),
4085 {
4086 move |_window, cx| {
4087 Tooltip::for_action_in(
4088 "New Thread\u{2026}",
4089 &ToggleNewThreadMenu,
4090 &focus_handle,
4091 cx,
4092 )
4093 }
4094 },
4095 )
4096 .anchor(Corner::TopRight)
4097 .with_handle(self.new_thread_menu_handle.clone())
4098 .menu(move |window, cx| new_thread_menu_builder(window, cx));
4099
4100 base_container
4101 .child(
4102 h_flex()
4103 .size_full()
4104 .gap(DynamicSpacing::Base04.rems(cx))
4105 .pl(DynamicSpacing::Base04.rems(cx))
4106 .child(match &self.active_view {
4107 ActiveView::History { .. } | ActiveView::Configuration => {
4108 self.render_toolbar_back_button(cx).into_any_element()
4109 }
4110 _ => selected_agent.into_any_element(),
4111 })
4112 .child(self.render_title_view(window, cx)),
4113 )
4114 .child(
4115 h_flex()
4116 .h_full()
4117 .flex_none()
4118 .gap_1()
4119 .pl_1()
4120 .pr_1()
4121 .child(new_thread_menu)
4122 .when(show_history_menu && !has_v2_flag, |this| {
4123 this.child(self.render_recent_entries_menu(
4124 IconName::MenuAltTemp,
4125 Corner::TopRight,
4126 cx,
4127 ))
4128 })
4129 .child(self.render_panel_options_menu(window, cx)),
4130 )
4131 .into_any_element()
4132 }
4133 }
4134
4135 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4136 let status = self.worktree_creation_status.as_ref()?;
4137 match status {
4138 WorktreeCreationStatus::Creating => Some(
4139 h_flex()
4140 .absolute()
4141 .bottom_12()
4142 .w_full()
4143 .p_2()
4144 .gap_1()
4145 .justify_center()
4146 .bg(cx.theme().colors().editor_background)
4147 .child(
4148 Icon::new(IconName::LoadCircle)
4149 .size(IconSize::Small)
4150 .color(Color::Muted)
4151 .with_rotate_animation(3),
4152 )
4153 .child(
4154 Label::new("Creating Worktree…")
4155 .color(Color::Muted)
4156 .size(LabelSize::Small),
4157 )
4158 .into_any_element(),
4159 ),
4160 WorktreeCreationStatus::Error(message) => Some(
4161 Callout::new()
4162 .icon(IconName::Warning)
4163 .severity(Severity::Warning)
4164 .title(message.clone())
4165 .into_any_element(),
4166 ),
4167 }
4168 }
4169
4170 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4171 if TrialEndUpsell::dismissed(cx) {
4172 return false;
4173 }
4174
4175 match &self.active_view {
4176 ActiveView::TextThread { .. } => {
4177 if LanguageModelRegistry::global(cx)
4178 .read(cx)
4179 .default_model()
4180 .is_some_and(|model| {
4181 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4182 })
4183 {
4184 return false;
4185 }
4186 }
4187 ActiveView::Uninitialized
4188 | ActiveView::AgentThread { .. }
4189 | ActiveView::History { .. }
4190 | ActiveView::Configuration => return false,
4191 }
4192
4193 let plan = self.user_store.read(cx).plan();
4194 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4195
4196 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4197 }
4198
4199 fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
4200 if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
4201 return false;
4202 }
4203
4204 let user_store = self.user_store.read(cx);
4205
4206 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4207 && user_store
4208 .subscription_period()
4209 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4210 .is_some_and(|date| date < chrono::Utc::now())
4211 {
4212 OnboardingUpsell::set_dismissed(true, cx);
4213 self.on_boarding_upsell_dismissed
4214 .store(true, Ordering::Release);
4215 return false;
4216 }
4217
4218 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4219 .visible_providers()
4220 .iter()
4221 .any(|provider| {
4222 provider.is_authenticated(cx)
4223 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4224 });
4225
4226 match &self.active_view {
4227 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4228 false
4229 }
4230 ActiveView::AgentThread {
4231 conversation_view, ..
4232 } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
4233 ActiveView::AgentThread { conversation_view } => {
4234 let history_is_empty = conversation_view
4235 .read(cx)
4236 .history()
4237 .is_none_or(|h| h.read(cx).is_empty());
4238 history_is_empty || !has_configured_non_zed_providers
4239 }
4240 ActiveView::TextThread { .. } => {
4241 let history_is_empty = self.text_thread_history.read(cx).is_empty();
4242 history_is_empty || !has_configured_non_zed_providers
4243 }
4244 }
4245 }
4246
4247 fn render_onboarding(
4248 &self,
4249 _window: &mut Window,
4250 cx: &mut Context<Self>,
4251 ) -> Option<impl IntoElement> {
4252 if !self.should_render_onboarding(cx) {
4253 return None;
4254 }
4255
4256 let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. });
4257
4258 Some(
4259 div()
4260 .when(text_thread_view, |this| {
4261 this.bg(cx.theme().colors().editor_background)
4262 })
4263 .child(self.onboarding.clone()),
4264 )
4265 }
4266
4267 fn render_trial_end_upsell(
4268 &self,
4269 _window: &mut Window,
4270 cx: &mut Context<Self>,
4271 ) -> Option<impl IntoElement> {
4272 if !self.should_render_trial_end_upsell(cx) {
4273 return None;
4274 }
4275
4276 Some(
4277 v_flex()
4278 .absolute()
4279 .inset_0()
4280 .size_full()
4281 .bg(cx.theme().colors().panel_background)
4282 .opacity(0.85)
4283 .block_mouse_except_scroll()
4284 .child(EndTrialUpsell::new(Arc::new({
4285 let this = cx.entity();
4286 move |_, cx| {
4287 this.update(cx, |_this, cx| {
4288 TrialEndUpsell::set_dismissed(true, cx);
4289 cx.notify();
4290 });
4291 }
4292 }))),
4293 )
4294 }
4295
4296 fn emit_configuration_error_telemetry_if_needed(
4297 &mut self,
4298 configuration_error: Option<&ConfigurationError>,
4299 ) {
4300 let error_kind = configuration_error.map(|err| match err {
4301 ConfigurationError::NoProvider => "no_provider",
4302 ConfigurationError::ModelNotFound => "model_not_found",
4303 ConfigurationError::ProviderNotAuthenticated(_) => "provider_not_authenticated",
4304 });
4305
4306 let error_kind_string = error_kind.map(String::from);
4307
4308 if self.last_configuration_error_telemetry == error_kind_string {
4309 return;
4310 }
4311
4312 self.last_configuration_error_telemetry = error_kind_string;
4313
4314 if let Some(kind) = error_kind {
4315 let message = configuration_error
4316 .map(|err| err.to_string())
4317 .unwrap_or_default();
4318
4319 telemetry::event!("Agent Panel Error Shown", kind = kind, message = message,);
4320 }
4321 }
4322
4323 fn render_configuration_error(
4324 &self,
4325 border_bottom: bool,
4326 configuration_error: &ConfigurationError,
4327 focus_handle: &FocusHandle,
4328 cx: &mut App,
4329 ) -> impl IntoElement {
4330 let zed_provider_configured = AgentSettings::get_global(cx)
4331 .default_model
4332 .as_ref()
4333 .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev");
4334
4335 let callout = if zed_provider_configured {
4336 Callout::new()
4337 .icon(IconName::Warning)
4338 .severity(Severity::Warning)
4339 .when(border_bottom, |this| {
4340 this.border_position(ui::BorderPosition::Bottom)
4341 })
4342 .title("Sign in to continue using Zed as your LLM provider.")
4343 .actions_slot(
4344 Button::new("sign_in", "Sign In")
4345 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
4346 .label_size(LabelSize::Small)
4347 .on_click({
4348 let workspace = self.workspace.clone();
4349 move |_, _, cx| {
4350 let Ok(client) =
4351 workspace.update(cx, |workspace, _| workspace.client().clone())
4352 else {
4353 return;
4354 };
4355
4356 cx.spawn(async move |cx| {
4357 client.sign_in_with_optional_connect(true, cx).await
4358 })
4359 .detach_and_log_err(cx);
4360 }
4361 }),
4362 )
4363 } else {
4364 Callout::new()
4365 .icon(IconName::Warning)
4366 .severity(Severity::Warning)
4367 .when(border_bottom, |this| {
4368 this.border_position(ui::BorderPosition::Bottom)
4369 })
4370 .title(configuration_error.to_string())
4371 .actions_slot(
4372 Button::new("settings", "Configure")
4373 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
4374 .label_size(LabelSize::Small)
4375 .key_binding(
4376 KeyBinding::for_action_in(&OpenSettings, focus_handle, cx)
4377 .map(|kb| kb.size(rems_from_px(12.))),
4378 )
4379 .on_click(|_event, window, cx| {
4380 window.dispatch_action(OpenSettings.boxed_clone(), cx)
4381 }),
4382 )
4383 };
4384
4385 match configuration_error {
4386 ConfigurationError::ModelNotFound
4387 | ConfigurationError::ProviderNotAuthenticated(_)
4388 | ConfigurationError::NoProvider => callout.into_any_element(),
4389 }
4390 }
4391
4392 fn render_text_thread(
4393 &self,
4394 text_thread_editor: &Entity<TextThreadEditor>,
4395 buffer_search_bar: &Entity<BufferSearchBar>,
4396 window: &mut Window,
4397 cx: &mut Context<Self>,
4398 ) -> Div {
4399 let mut registrar = buffer_search::DivRegistrar::new(
4400 |this, _, _cx| match &this.active_view {
4401 ActiveView::TextThread {
4402 buffer_search_bar, ..
4403 } => Some(buffer_search_bar.clone()),
4404 _ => None,
4405 },
4406 cx,
4407 );
4408 BufferSearchBar::register(&mut registrar);
4409 registrar
4410 .into_div()
4411 .size_full()
4412 .relative()
4413 .map(|parent| {
4414 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
4415 if buffer_search_bar.is_dismissed() {
4416 return parent;
4417 }
4418 parent.child(
4419 div()
4420 .p(DynamicSpacing::Base08.rems(cx))
4421 .border_b_1()
4422 .border_color(cx.theme().colors().border_variant)
4423 .bg(cx.theme().colors().editor_background)
4424 .child(buffer_search_bar.render(window, cx)),
4425 )
4426 })
4427 })
4428 .child(text_thread_editor.clone())
4429 .child(self.render_drag_target(cx))
4430 }
4431
4432 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4433 let is_local = self.project.read(cx).is_local();
4434 div()
4435 .invisible()
4436 .absolute()
4437 .top_0()
4438 .right_0()
4439 .bottom_0()
4440 .left_0()
4441 .bg(cx.theme().colors().drop_target_background)
4442 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4443 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4444 .when(is_local, |this| {
4445 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4446 })
4447 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4448 let item = tab.pane.read(cx).item_for_index(tab.ix);
4449 let project_paths = item
4450 .and_then(|item| item.project_path(cx))
4451 .into_iter()
4452 .collect::<Vec<_>>();
4453 this.handle_drop(project_paths, vec![], window, cx);
4454 }))
4455 .on_drop(
4456 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4457 let project_paths = selection
4458 .items()
4459 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4460 .collect::<Vec<_>>();
4461 this.handle_drop(project_paths, vec![], window, cx);
4462 }),
4463 )
4464 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4465 let tasks = paths
4466 .paths()
4467 .iter()
4468 .map(|path| {
4469 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4470 })
4471 .collect::<Vec<_>>();
4472 cx.spawn_in(window, async move |this, cx| {
4473 let mut paths = vec![];
4474 let mut added_worktrees = vec![];
4475 let opened_paths = futures::future::join_all(tasks).await;
4476 for entry in opened_paths {
4477 if let Some((worktree, project_path)) = entry.log_err() {
4478 added_worktrees.push(worktree);
4479 paths.push(project_path);
4480 }
4481 }
4482 this.update_in(cx, |this, window, cx| {
4483 this.handle_drop(paths, added_worktrees, window, cx);
4484 })
4485 .ok();
4486 })
4487 .detach();
4488 }))
4489 }
4490
4491 fn handle_drop(
4492 &mut self,
4493 paths: Vec<ProjectPath>,
4494 added_worktrees: Vec<Entity<Worktree>>,
4495 window: &mut Window,
4496 cx: &mut Context<Self>,
4497 ) {
4498 match &self.active_view {
4499 ActiveView::AgentThread { conversation_view } => {
4500 conversation_view.update(cx, |conversation_view, cx| {
4501 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4502 });
4503 }
4504 ActiveView::TextThread {
4505 text_thread_editor, ..
4506 } => {
4507 text_thread_editor.update(cx, |text_thread_editor, cx| {
4508 TextThreadEditor::insert_dragged_files(
4509 text_thread_editor,
4510 paths,
4511 added_worktrees,
4512 window,
4513 cx,
4514 );
4515 });
4516 }
4517 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4518 }
4519 }
4520
4521 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4522 if !self.show_trust_workspace_message {
4523 return None;
4524 }
4525
4526 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4527
4528 Some(
4529 Callout::new()
4530 .icon(IconName::Warning)
4531 .severity(Severity::Warning)
4532 .border_position(ui::BorderPosition::Bottom)
4533 .title("You're in Restricted Mode")
4534 .description(description)
4535 .actions_slot(
4536 Button::new("open-trust-modal", "Configure Project Trust")
4537 .label_size(LabelSize::Small)
4538 .style(ButtonStyle::Outlined)
4539 .on_click({
4540 cx.listener(move |this, _, window, cx| {
4541 this.workspace
4542 .update(cx, |workspace, cx| {
4543 workspace
4544 .show_worktree_trust_security_modal(true, window, cx)
4545 })
4546 .log_err();
4547 })
4548 }),
4549 ),
4550 )
4551 }
4552
4553 fn key_context(&self) -> KeyContext {
4554 let mut key_context = KeyContext::new_with_defaults();
4555 key_context.add("AgentPanel");
4556 match &self.active_view {
4557 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4558 ActiveView::TextThread { .. } => key_context.add("text_thread"),
4559 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4560 }
4561 key_context
4562 }
4563}
4564
4565impl Render for AgentPanel {
4566 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4567 // WARNING: Changes to this element hierarchy can have
4568 // non-obvious implications to the layout of children.
4569 //
4570 // If you need to change it, please confirm:
4571 // - The message editor expands (cmd-option-esc) correctly
4572 // - When expanded, the buttons at the bottom of the panel are displayed correctly
4573 // - Font size works as expected and can be changed with cmd-+/cmd-
4574 // - Scrolling in all views works as expected
4575 // - Files can be dropped into the panel
4576 let content = v_flex()
4577 .relative()
4578 .size_full()
4579 .justify_between()
4580 .key_context(self.key_context())
4581 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4582 this.new_thread(action, window, cx);
4583 }))
4584 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4585 this.open_history(window, cx);
4586 }))
4587 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4588 this.open_configuration(window, cx);
4589 }))
4590 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4591 .on_action(cx.listener(Self::deploy_rules_library))
4592 .on_action(cx.listener(Self::go_back))
4593 .on_action(cx.listener(Self::toggle_navigation_menu))
4594 .on_action(cx.listener(Self::toggle_options_menu))
4595 .on_action(cx.listener(Self::increase_font_size))
4596 .on_action(cx.listener(Self::decrease_font_size))
4597 .on_action(cx.listener(Self::reset_font_size))
4598 .on_action(cx.listener(Self::toggle_zoom))
4599 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4600 if let Some(conversation_view) = this.active_conversation_view() {
4601 conversation_view.update(cx, |conversation_view, cx| {
4602 conversation_view.reauthenticate(window, cx)
4603 })
4604 }
4605 }))
4606 .child(self.render_toolbar(window, cx))
4607 .children(self.render_workspace_trust_message(cx))
4608 .children(self.render_onboarding(window, cx))
4609 .map(|parent| {
4610 // Emit configuration error telemetry before entering the match to avoid borrow conflicts
4611 if matches!(&self.active_view, ActiveView::TextThread { .. }) {
4612 let model_registry = LanguageModelRegistry::read_global(cx);
4613 let configuration_error =
4614 model_registry.configuration_error(model_registry.default_model(), cx);
4615 self.emit_configuration_error_telemetry_if_needed(configuration_error.as_ref());
4616 }
4617
4618 match &self.active_view {
4619 ActiveView::Uninitialized => parent,
4620 ActiveView::AgentThread {
4621 conversation_view, ..
4622 } => parent
4623 .child(conversation_view.clone())
4624 .child(self.render_drag_target(cx)),
4625 ActiveView::History { history: kind } => match kind {
4626 History::AgentThreads { view } => parent.child(view.clone()),
4627 History::TextThreads => parent.child(self.text_thread_history.clone()),
4628 },
4629 ActiveView::TextThread {
4630 text_thread_editor,
4631 buffer_search_bar,
4632 ..
4633 } => {
4634 let model_registry = LanguageModelRegistry::read_global(cx);
4635 let configuration_error =
4636 model_registry.configuration_error(model_registry.default_model(), cx);
4637
4638 parent
4639 .map(|this| {
4640 if !self.should_render_onboarding(cx)
4641 && let Some(err) = configuration_error.as_ref()
4642 {
4643 this.child(self.render_configuration_error(
4644 true,
4645 err,
4646 &self.focus_handle(cx),
4647 cx,
4648 ))
4649 } else {
4650 this
4651 }
4652 })
4653 .child(self.render_text_thread(
4654 text_thread_editor,
4655 buffer_search_bar,
4656 window,
4657 cx,
4658 ))
4659 }
4660 ActiveView::Configuration => parent.children(self.configuration.clone()),
4661 }
4662 })
4663 .children(self.render_worktree_creation_status(cx))
4664 .children(self.render_trial_end_upsell(window, cx));
4665
4666 match self.active_view.which_font_size_used() {
4667 WhichFontSize::AgentFont => {
4668 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4669 .size_full()
4670 .child(content)
4671 .into_any()
4672 }
4673 _ => content.into_any(),
4674 }
4675 }
4676}
4677
4678struct PromptLibraryInlineAssist {
4679 workspace: WeakEntity<Workspace>,
4680}
4681
4682impl PromptLibraryInlineAssist {
4683 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4684 Self { workspace }
4685 }
4686}
4687
4688impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4689 fn assist(
4690 &self,
4691 prompt_editor: &Entity<Editor>,
4692 initial_prompt: Option<String>,
4693 window: &mut Window,
4694 cx: &mut Context<RulesLibrary>,
4695 ) {
4696 InlineAssistant::update_global(cx, |assistant, cx| {
4697 let Some(workspace) = self.workspace.upgrade() else {
4698 return;
4699 };
4700 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4701 return;
4702 };
4703 let history = panel
4704 .read(cx)
4705 .connection_store()
4706 .read(cx)
4707 .entry(&crate::Agent::NativeAgent)
4708 .and_then(|s| s.read(cx).history())
4709 .map(|h| h.downgrade());
4710 let project = workspace.read(cx).project().downgrade();
4711 let panel = panel.read(cx);
4712 let thread_store = panel.thread_store().clone();
4713 assistant.assist(
4714 prompt_editor,
4715 self.workspace.clone(),
4716 project,
4717 thread_store,
4718 None,
4719 history,
4720 initial_prompt,
4721 window,
4722 cx,
4723 );
4724 })
4725 }
4726
4727 fn focus_agent_panel(
4728 &self,
4729 workspace: &mut Workspace,
4730 window: &mut Window,
4731 cx: &mut Context<Workspace>,
4732 ) -> bool {
4733 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4734 }
4735}
4736
4737pub struct ConcreteAssistantPanelDelegate;
4738
4739impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
4740 fn active_text_thread_editor(
4741 &self,
4742 workspace: &mut Workspace,
4743 _window: &mut Window,
4744 cx: &mut Context<Workspace>,
4745 ) -> Option<Entity<TextThreadEditor>> {
4746 let panel = workspace.panel::<AgentPanel>(cx)?;
4747 panel.read(cx).active_text_thread_editor()
4748 }
4749
4750 fn open_local_text_thread(
4751 &self,
4752 workspace: &mut Workspace,
4753 path: Arc<Path>,
4754 window: &mut Window,
4755 cx: &mut Context<Workspace>,
4756 ) -> Task<Result<()>> {
4757 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4758 return Task::ready(Err(anyhow!("Agent panel not found")));
4759 };
4760
4761 panel.update(cx, |panel, cx| {
4762 panel.open_saved_text_thread(path, window, cx)
4763 })
4764 }
4765
4766 fn open_remote_text_thread(
4767 &self,
4768 _workspace: &mut Workspace,
4769 _text_thread_id: assistant_text_thread::TextThreadId,
4770 _window: &mut Window,
4771 _cx: &mut Context<Workspace>,
4772 ) -> Task<Result<Entity<TextThreadEditor>>> {
4773 Task::ready(Err(anyhow!("opening remote context not implemented")))
4774 }
4775
4776 fn quote_selection(
4777 &self,
4778 workspace: &mut Workspace,
4779 selection_ranges: Vec<Range<Anchor>>,
4780 buffer: Entity<MultiBuffer>,
4781 window: &mut Window,
4782 cx: &mut Context<Workspace>,
4783 ) {
4784 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4785 return;
4786 };
4787
4788 if !panel.focus_handle(cx).contains_focused(window, cx) {
4789 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
4790 }
4791
4792 panel.update(cx, |_, cx| {
4793 // Wait to create a new context until the workspace is no longer
4794 // being updated.
4795 cx.defer_in(window, move |panel, window, cx| {
4796 if let Some(conversation_view) = panel.active_conversation_view() {
4797 conversation_view.update(cx, |conversation_view, cx| {
4798 conversation_view.insert_selections(window, cx);
4799 });
4800 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
4801 let snapshot = buffer.read(cx).snapshot(cx);
4802 let selection_ranges = selection_ranges
4803 .into_iter()
4804 .map(|range| range.to_point(&snapshot))
4805 .collect::<Vec<_>>();
4806
4807 text_thread_editor.update(cx, |text_thread_editor, cx| {
4808 text_thread_editor.quote_ranges(selection_ranges, snapshot, window, cx)
4809 });
4810 }
4811 });
4812 });
4813 }
4814
4815 fn quote_terminal_text(
4816 &self,
4817 workspace: &mut Workspace,
4818 text: String,
4819 window: &mut Window,
4820 cx: &mut Context<Workspace>,
4821 ) {
4822 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4823 return;
4824 };
4825
4826 if !panel.focus_handle(cx).contains_focused(window, cx) {
4827 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
4828 }
4829
4830 panel.update(cx, |_, cx| {
4831 // Wait to create a new context until the workspace is no longer
4832 // being updated.
4833 cx.defer_in(window, move |panel, window, cx| {
4834 if let Some(conversation_view) = panel.active_conversation_view() {
4835 conversation_view.update(cx, |conversation_view, cx| {
4836 conversation_view.insert_terminal_text(text, window, cx);
4837 });
4838 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
4839 text_thread_editor.update(cx, |text_thread_editor, cx| {
4840 text_thread_editor.quote_terminal_text(text, window, cx)
4841 });
4842 }
4843 });
4844 });
4845 }
4846}
4847
4848struct OnboardingUpsell;
4849
4850impl Dismissable for OnboardingUpsell {
4851 const KEY: &'static str = "dismissed-trial-upsell";
4852}
4853
4854struct TrialEndUpsell;
4855
4856impl Dismissable for TrialEndUpsell {
4857 const KEY: &'static str = "dismissed-trial-end-upsell";
4858}
4859
4860/// Test-only helper methods
4861#[cfg(any(test, feature = "test-support"))]
4862impl AgentPanel {
4863 pub fn test_new(
4864 workspace: &Workspace,
4865 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
4866 window: &mut Window,
4867 cx: &mut Context<Self>,
4868 ) -> Self {
4869 Self::new(workspace, text_thread_store, None, window, cx)
4870 }
4871
4872 /// Opens an external thread using an arbitrary AgentServer.
4873 ///
4874 /// This is a test-only helper that allows visual tests and integration tests
4875 /// to inject a stub server without modifying production code paths.
4876 /// Not compiled into production builds.
4877 pub fn open_external_thread_with_server(
4878 &mut self,
4879 server: Rc<dyn AgentServer>,
4880 window: &mut Window,
4881 cx: &mut Context<Self>,
4882 ) {
4883 let workspace = self.workspace.clone();
4884 let project = self.project.clone();
4885
4886 let ext_agent = Agent::Custom {
4887 id: server.agent_id(),
4888 };
4889
4890 self.create_agent_thread(
4891 server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
4892 );
4893 }
4894
4895 /// Returns the currently active thread view, if any.
4896 ///
4897 /// This is a test-only accessor that exposes the private `active_thread_view()`
4898 /// method for test assertions. Not compiled into production builds.
4899 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4900 self.active_conversation_view()
4901 }
4902
4903 /// Sets the start_thread_in value directly, bypassing validation.
4904 ///
4905 /// This is a test-only helper for visual tests that need to show specific
4906 /// start_thread_in states without requiring a real git repository.
4907 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4908 self.start_thread_in = target;
4909 cx.notify();
4910 }
4911
4912 /// Returns the current worktree creation status.
4913 ///
4914 /// This is a test-only helper for visual tests.
4915 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4916 self.worktree_creation_status.as_ref()
4917 }
4918
4919 /// Sets the worktree creation status directly.
4920 ///
4921 /// This is a test-only helper for visual tests that need to show the
4922 /// "Creating worktree…" spinner or error banners.
4923 pub fn set_worktree_creation_status_for_tests(
4924 &mut self,
4925 status: Option<WorktreeCreationStatus>,
4926 cx: &mut Context<Self>,
4927 ) {
4928 self.worktree_creation_status = status;
4929 cx.notify();
4930 }
4931
4932 /// Opens the history view.
4933 ///
4934 /// This is a test-only helper that exposes the private `open_history()`
4935 /// method for visual tests.
4936 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4937 self.open_history(window, cx);
4938 }
4939
4940 /// Opens the start_thread_in selector popover menu.
4941 ///
4942 /// This is a test-only helper for visual tests.
4943 pub fn open_start_thread_in_menu_for_tests(
4944 &mut self,
4945 window: &mut Window,
4946 cx: &mut Context<Self>,
4947 ) {
4948 self.start_thread_in_menu_handle.show(window, cx);
4949 }
4950
4951 /// Dismisses the start_thread_in dropdown menu.
4952 ///
4953 /// This is a test-only helper for visual tests.
4954 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4955 self.start_thread_in_menu_handle.hide(cx);
4956 }
4957}
4958
4959#[cfg(test)]
4960mod tests {
4961 use super::*;
4962 use crate::conversation_view::tests::{StubAgentServer, init_test};
4963 use crate::test_support::{
4964 active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4965 send_message,
4966 };
4967 use acp_thread::{StubAgentConnection, ThreadStatus};
4968 use agent_servers::CODEX_ID;
4969 use assistant_text_thread::TextThreadStore;
4970 use feature_flags::FeatureFlagAppExt;
4971 use fs::FakeFs;
4972 use gpui::{TestAppContext, VisualTestContext};
4973 use project::Project;
4974 use serde_json::json;
4975 use std::time::Instant;
4976 use workspace::MultiWorkspace;
4977
4978 #[gpui::test]
4979 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4980 init_test(cx);
4981 cx.update(|cx| {
4982 cx.update_flags(true, vec!["agent-v2".to_string()]);
4983 agent::ThreadStore::init_global(cx);
4984 language_model::LanguageModelRegistry::test(cx);
4985 });
4986
4987 // --- Create a MultiWorkspace window with two workspaces ---
4988 let fs = FakeFs::new(cx.executor());
4989 let project_a = Project::test(fs.clone(), [], cx).await;
4990 let project_b = Project::test(fs, [], cx).await;
4991
4992 let multi_workspace =
4993 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4994
4995 let workspace_a = multi_workspace
4996 .read_with(cx, |multi_workspace, _cx| {
4997 multi_workspace.workspace().clone()
4998 })
4999 .unwrap();
5000
5001 let workspace_b = multi_workspace
5002 .update(cx, |multi_workspace, window, cx| {
5003 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
5004 })
5005 .unwrap();
5006
5007 workspace_a.update(cx, |workspace, _cx| {
5008 workspace.set_random_database_id();
5009 });
5010 workspace_b.update(cx, |workspace, _cx| {
5011 workspace.set_random_database_id();
5012 });
5013
5014 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5015
5016 // --- Set up workspace A: width=300, with an active thread ---
5017 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
5018 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx));
5019 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
5020 });
5021
5022 panel_a.update(cx, |panel, _cx| {
5023 panel.width = Some(px(300.0));
5024 });
5025
5026 panel_a.update_in(cx, |panel, window, cx| {
5027 panel.open_external_thread_with_server(
5028 Rc::new(StubAgentServer::default_response()),
5029 window,
5030 cx,
5031 );
5032 });
5033
5034 cx.run_until_parked();
5035
5036 panel_a.read_with(cx, |panel, cx| {
5037 assert!(
5038 panel.active_agent_thread(cx).is_some(),
5039 "workspace A should have an active thread after connection"
5040 );
5041 });
5042
5043 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent_type.clone());
5044
5045 // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
5046 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
5047 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx));
5048 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
5049 });
5050
5051 panel_b.update(cx, |panel, _cx| {
5052 panel.width = Some(px(400.0));
5053 panel.selected_agent_type = AgentType::Custom {
5054 id: "claude-acp".into(),
5055 };
5056 });
5057
5058 // --- Serialize both panels ---
5059 panel_a.update(cx, |panel, cx| panel.serialize(cx));
5060 panel_b.update(cx, |panel, cx| panel.serialize(cx));
5061 cx.run_until_parked();
5062
5063 // --- Load fresh panels for each workspace and verify independent state ---
5064 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
5065
5066 let async_cx = cx.update(|window, cx| window.to_async(cx));
5067 let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx)
5068 .await
5069 .expect("panel A load should succeed");
5070 cx.run_until_parked();
5071
5072 let async_cx = cx.update(|window, cx| window.to_async(cx));
5073 let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx)
5074 .await
5075 .expect("panel B load should succeed");
5076 cx.run_until_parked();
5077
5078 // Workspace A should restore its thread, width, and agent type
5079 loaded_a.read_with(cx, |panel, _cx| {
5080 assert_eq!(
5081 panel.width,
5082 Some(px(300.0)),
5083 "workspace A width should be restored"
5084 );
5085 assert_eq!(
5086 panel.selected_agent_type, agent_type_a,
5087 "workspace A agent type should be restored"
5088 );
5089 assert!(
5090 panel.active_conversation_view().is_some(),
5091 "workspace A should have its active thread restored"
5092 );
5093 });
5094
5095 // Workspace B should restore its own width and agent type, with no thread
5096 loaded_b.read_with(cx, |panel, _cx| {
5097 assert_eq!(
5098 panel.width,
5099 Some(px(400.0)),
5100 "workspace B width should be restored"
5101 );
5102 assert_eq!(
5103 panel.selected_agent_type,
5104 AgentType::Custom {
5105 id: "claude-acp".into()
5106 },
5107 "workspace B agent type should be restored"
5108 );
5109 assert!(
5110 panel.active_conversation_view().is_none(),
5111 "workspace B should have no active thread"
5112 );
5113 });
5114 }
5115
5116 // Simple regression test
5117 #[gpui::test]
5118 async fn test_new_text_thread_action_handler(cx: &mut TestAppContext) {
5119 init_test(cx);
5120
5121 let fs = FakeFs::new(cx.executor());
5122
5123 cx.update(|cx| {
5124 cx.update_flags(true, vec!["agent-v2".to_string()]);
5125 agent::ThreadStore::init_global(cx);
5126 language_model::LanguageModelRegistry::test(cx);
5127 let slash_command_registry =
5128 assistant_slash_command::SlashCommandRegistry::default_global(cx);
5129 slash_command_registry
5130 .register_command(assistant_slash_commands::DefaultSlashCommand, false);
5131 <dyn fs::Fs>::set_global(fs.clone(), cx);
5132 });
5133
5134 let project = Project::test(fs.clone(), [], cx).await;
5135
5136 let multi_workspace =
5137 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5138
5139 let workspace_a = multi_workspace
5140 .read_with(cx, |multi_workspace, _cx| {
5141 multi_workspace.workspace().clone()
5142 })
5143 .unwrap();
5144
5145 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5146
5147 workspace_a.update_in(cx, |workspace, window, cx| {
5148 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5149 let panel =
5150 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
5151 workspace.add_panel(panel, window, cx);
5152 });
5153
5154 cx.run_until_parked();
5155
5156 workspace_a.update_in(cx, |_, window, cx| {
5157 window.dispatch_action(NewTextThread.boxed_clone(), cx);
5158 });
5159
5160 cx.run_until_parked();
5161 }
5162
5163 /// Extracts the text from a Text content block, panicking if it's not Text.
5164 fn expect_text_block(block: &acp::ContentBlock) -> &str {
5165 match block {
5166 acp::ContentBlock::Text(t) => t.text.as_str(),
5167 other => panic!("expected Text block, got {:?}", other),
5168 }
5169 }
5170
5171 /// Extracts the (text_content, uri) from a Resource content block, panicking
5172 /// if it's not a TextResourceContents resource.
5173 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
5174 match block {
5175 acp::ContentBlock::Resource(r) => match &r.resource {
5176 acp::EmbeddedResourceResource::TextResourceContents(t) => {
5177 (t.text.as_str(), t.uri.as_str())
5178 }
5179 other => panic!("expected TextResourceContents, got {:?}", other),
5180 },
5181 other => panic!("expected Resource block, got {:?}", other),
5182 }
5183 }
5184
5185 #[test]
5186 fn test_build_conflict_resolution_prompt_single_conflict() {
5187 let conflicts = vec![ConflictContent {
5188 file_path: "src/main.rs".to_string(),
5189 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
5190 .to_string(),
5191 ours_branch_name: "HEAD".to_string(),
5192 theirs_branch_name: "feature".to_string(),
5193 }];
5194
5195 let blocks = build_conflict_resolution_prompt(&conflicts);
5196 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
5197 assert_eq!(
5198 blocks.len(),
5199 4,
5200 "expected 2 text + 1 resource link + 1 resource block"
5201 );
5202
5203 let intro_text = expect_text_block(&blocks[0]);
5204 assert!(
5205 intro_text.contains("Please resolve the following merge conflict in"),
5206 "prompt should include single-conflict intro text"
5207 );
5208
5209 match &blocks[1] {
5210 acp::ContentBlock::ResourceLink(link) => {
5211 assert!(
5212 link.uri.contains("file://"),
5213 "resource link URI should use file scheme"
5214 );
5215 assert!(
5216 link.uri.contains("main.rs"),
5217 "resource link URI should reference file path"
5218 );
5219 }
5220 other => panic!("expected ResourceLink block, got {:?}", other),
5221 }
5222
5223 let body_text = expect_text_block(&blocks[2]);
5224 assert!(
5225 body_text.contains("`HEAD` (ours)"),
5226 "prompt should mention ours branch"
5227 );
5228 assert!(
5229 body_text.contains("`feature` (theirs)"),
5230 "prompt should mention theirs branch"
5231 );
5232 assert!(
5233 body_text.contains("editing the file directly"),
5234 "prompt should instruct the agent to edit the file"
5235 );
5236
5237 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
5238 assert!(
5239 resource_text.contains("<<<<<<< HEAD"),
5240 "resource should contain the conflict text"
5241 );
5242 assert!(
5243 resource_uri.contains("merge-conflict"),
5244 "resource URI should use the merge-conflict scheme"
5245 );
5246 assert!(
5247 resource_uri.contains("main.rs"),
5248 "resource URI should reference the file path"
5249 );
5250 }
5251
5252 #[test]
5253 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
5254 let conflicts = vec![
5255 ConflictContent {
5256 file_path: "src/lib.rs".to_string(),
5257 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
5258 .to_string(),
5259 ours_branch_name: "main".to_string(),
5260 theirs_branch_name: "dev".to_string(),
5261 },
5262 ConflictContent {
5263 file_path: "src/lib.rs".to_string(),
5264 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5265 .to_string(),
5266 ours_branch_name: "main".to_string(),
5267 theirs_branch_name: "dev".to_string(),
5268 },
5269 ];
5270
5271 let blocks = build_conflict_resolution_prompt(&conflicts);
5272 // 1 Text instruction + 2 Resource blocks
5273 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5274
5275 let text = expect_text_block(&blocks[0]);
5276 assert!(
5277 text.contains("all 2 merge conflicts"),
5278 "prompt should mention the total count"
5279 );
5280 assert!(
5281 text.contains("`main` (ours)"),
5282 "prompt should mention ours branch"
5283 );
5284 assert!(
5285 text.contains("`dev` (theirs)"),
5286 "prompt should mention theirs branch"
5287 );
5288 // Single file, so "file" not "files"
5289 assert!(
5290 text.contains("file directly"),
5291 "single file should use singular 'file'"
5292 );
5293
5294 let (resource_a, _) = expect_resource_block(&blocks[1]);
5295 let (resource_b, _) = expect_resource_block(&blocks[2]);
5296 assert!(
5297 resource_a.contains("fn a()"),
5298 "first resource should contain first conflict"
5299 );
5300 assert!(
5301 resource_b.contains("fn b()"),
5302 "second resource should contain second conflict"
5303 );
5304 }
5305
5306 #[test]
5307 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5308 let conflicts = vec![
5309 ConflictContent {
5310 file_path: "src/a.rs".to_string(),
5311 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5312 ours_branch_name: "main".to_string(),
5313 theirs_branch_name: "dev".to_string(),
5314 },
5315 ConflictContent {
5316 file_path: "src/b.rs".to_string(),
5317 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5318 ours_branch_name: "main".to_string(),
5319 theirs_branch_name: "dev".to_string(),
5320 },
5321 ];
5322
5323 let blocks = build_conflict_resolution_prompt(&conflicts);
5324 // 1 Text instruction + 2 Resource blocks
5325 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5326
5327 let text = expect_text_block(&blocks[0]);
5328 assert!(
5329 text.contains("files directly"),
5330 "multiple files should use plural 'files'"
5331 );
5332
5333 let (_, uri_a) = expect_resource_block(&blocks[1]);
5334 let (_, uri_b) = expect_resource_block(&blocks[2]);
5335 assert!(
5336 uri_a.contains("a.rs"),
5337 "first resource URI should reference a.rs"
5338 );
5339 assert!(
5340 uri_b.contains("b.rs"),
5341 "second resource URI should reference b.rs"
5342 );
5343 }
5344
5345 #[test]
5346 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5347 let file_paths = vec![
5348 "src/main.rs".to_string(),
5349 "src/lib.rs".to_string(),
5350 "tests/integration.rs".to_string(),
5351 ];
5352
5353 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5354 // 1 instruction Text block + (ResourceLink + newline Text) per file
5355 assert_eq!(
5356 blocks.len(),
5357 1 + (file_paths.len() * 2),
5358 "expected instruction text plus resource links and separators"
5359 );
5360
5361 let text = expect_text_block(&blocks[0]);
5362 assert!(
5363 text.contains("unresolved merge conflicts"),
5364 "prompt should describe the task"
5365 );
5366 assert!(
5367 text.contains("conflict markers"),
5368 "prompt should mention conflict markers"
5369 );
5370
5371 for (index, path) in file_paths.iter().enumerate() {
5372 let link_index = 1 + (index * 2);
5373 let newline_index = link_index + 1;
5374
5375 match &blocks[link_index] {
5376 acp::ContentBlock::ResourceLink(link) => {
5377 assert!(
5378 link.uri.contains("file://"),
5379 "resource link URI should use file scheme"
5380 );
5381 assert!(
5382 link.uri.contains(path),
5383 "resource link URI should reference file path: {path}"
5384 );
5385 }
5386 other => panic!(
5387 "expected ResourceLink block at index {}, got {:?}",
5388 link_index, other
5389 ),
5390 }
5391
5392 let separator = expect_text_block(&blocks[newline_index]);
5393 assert_eq!(
5394 separator, "\n",
5395 "expected newline separator after each file"
5396 );
5397 }
5398 }
5399
5400 #[test]
5401 fn test_build_conflict_resolution_prompt_empty_conflicts() {
5402 let blocks = build_conflict_resolution_prompt(&[]);
5403 assert!(
5404 blocks.is_empty(),
5405 "empty conflicts should produce no blocks, got {} blocks",
5406 blocks.len()
5407 );
5408 }
5409
5410 #[test]
5411 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5412 let blocks = build_conflicted_files_resolution_prompt(&[]);
5413 assert!(
5414 blocks.is_empty(),
5415 "empty paths should produce no blocks, got {} blocks",
5416 blocks.len()
5417 );
5418 }
5419
5420 #[test]
5421 fn test_conflict_resource_block_structure() {
5422 let conflict = ConflictContent {
5423 file_path: "src/utils.rs".to_string(),
5424 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5425 ours_branch_name: "HEAD".to_string(),
5426 theirs_branch_name: "branch".to_string(),
5427 };
5428
5429 let block = conflict_resource_block(&conflict);
5430 let (text, uri) = expect_resource_block(&block);
5431
5432 assert_eq!(
5433 text, conflict.conflict_text,
5434 "resource text should be the raw conflict"
5435 );
5436 assert!(
5437 uri.starts_with("zed:///agent/merge-conflict"),
5438 "URI should use the zed merge-conflict scheme, got: {uri}"
5439 );
5440 assert!(uri.contains("utils.rs"), "URI should encode the file path");
5441 }
5442
5443 fn open_generating_thread_with_loadable_connection(
5444 panel: &Entity<AgentPanel>,
5445 connection: &StubAgentConnection,
5446 cx: &mut VisualTestContext,
5447 ) -> acp::SessionId {
5448 open_thread_with_custom_connection(panel, connection.clone(), cx);
5449 let session_id = active_session_id(panel, cx);
5450 send_message(panel, cx);
5451 cx.update(|_, cx| {
5452 connection.send_update(
5453 session_id.clone(),
5454 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5455 cx,
5456 );
5457 });
5458 cx.run_until_parked();
5459 session_id
5460 }
5461
5462 fn open_idle_thread_with_non_loadable_connection(
5463 panel: &Entity<AgentPanel>,
5464 connection: &StubAgentConnection,
5465 cx: &mut VisualTestContext,
5466 ) -> acp::SessionId {
5467 open_thread_with_custom_connection(panel, connection.clone(), cx);
5468 let session_id = active_session_id(panel, cx);
5469
5470 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5471 acp::ContentChunk::new("done".into()),
5472 )]);
5473 send_message(panel, cx);
5474
5475 session_id
5476 }
5477
5478 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5479 init_test(cx);
5480 cx.update(|cx| {
5481 cx.update_flags(true, vec!["agent-v2".to_string()]);
5482 agent::ThreadStore::init_global(cx);
5483 language_model::LanguageModelRegistry::test(cx);
5484 });
5485
5486 let fs = FakeFs::new(cx.executor());
5487 let project = Project::test(fs.clone(), [], cx).await;
5488
5489 let multi_workspace =
5490 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5491
5492 let workspace = multi_workspace
5493 .read_with(cx, |mw, _cx| mw.workspace().clone())
5494 .unwrap();
5495
5496 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5497
5498 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5499 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5500 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
5501 });
5502
5503 (panel, cx)
5504 }
5505
5506 #[gpui::test]
5507 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5508 let (panel, mut cx) = setup_panel(cx).await;
5509
5510 let connection_a = StubAgentConnection::new();
5511 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5512 send_message(&panel, &mut cx);
5513
5514 let session_id_a = active_session_id(&panel, &cx);
5515
5516 // Send a chunk to keep thread A generating (don't end the turn).
5517 cx.update(|_, cx| {
5518 connection_a.send_update(
5519 session_id_a.clone(),
5520 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5521 cx,
5522 );
5523 });
5524 cx.run_until_parked();
5525
5526 // Verify thread A is generating.
5527 panel.read_with(&cx, |panel, cx| {
5528 let thread = panel.active_agent_thread(cx).unwrap();
5529 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5530 assert!(panel.background_threads.is_empty());
5531 });
5532
5533 // Open a new thread B — thread A should be retained in background.
5534 let connection_b = StubAgentConnection::new();
5535 open_thread_with_connection(&panel, connection_b, &mut cx);
5536
5537 panel.read_with(&cx, |panel, _cx| {
5538 assert_eq!(
5539 panel.background_threads.len(),
5540 1,
5541 "Running thread A should be retained in background_views"
5542 );
5543 assert!(
5544 panel.background_threads.contains_key(&session_id_a),
5545 "Background view should be keyed by thread A's session ID"
5546 );
5547 });
5548 }
5549
5550 #[gpui::test]
5551 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5552 let (panel, mut cx) = setup_panel(cx).await;
5553
5554 let connection_a = StubAgentConnection::new();
5555 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5556 acp::ContentChunk::new("Response".into()),
5557 )]);
5558 open_thread_with_connection(&panel, connection_a, &mut cx);
5559 send_message(&panel, &mut cx);
5560
5561 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5562 panel.active_conversation_view().unwrap().downgrade()
5563 });
5564 let session_id_a = active_session_id(&panel, &cx);
5565
5566 // Thread A should be idle (auto-completed via set_next_prompt_updates).
5567 panel.read_with(&cx, |panel, cx| {
5568 let thread = panel.active_agent_thread(cx).unwrap();
5569 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5570 });
5571
5572 // Open a new thread B — thread A should be retained because it is not loadable.
5573 let connection_b = StubAgentConnection::new();
5574 open_thread_with_connection(&panel, connection_b, &mut cx);
5575
5576 panel.read_with(&cx, |panel, _cx| {
5577 assert_eq!(
5578 panel.background_threads.len(),
5579 1,
5580 "Idle non-loadable thread A should be retained in background_views"
5581 );
5582 assert!(
5583 panel.background_threads.contains_key(&session_id_a),
5584 "Background view should be keyed by thread A's session ID"
5585 );
5586 });
5587
5588 assert!(
5589 weak_view_a.upgrade().is_some(),
5590 "Idle non-loadable ConnectionView should still be retained"
5591 );
5592 }
5593
5594 #[gpui::test]
5595 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5596 let (panel, mut cx) = setup_panel(cx).await;
5597
5598 let connection_a = StubAgentConnection::new();
5599 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5600 send_message(&panel, &mut cx);
5601
5602 let session_id_a = active_session_id(&panel, &cx);
5603
5604 // Keep thread A generating.
5605 cx.update(|_, cx| {
5606 connection_a.send_update(
5607 session_id_a.clone(),
5608 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5609 cx,
5610 );
5611 });
5612 cx.run_until_parked();
5613
5614 // Open thread B — thread A goes to background.
5615 let connection_b = StubAgentConnection::new();
5616 open_thread_with_connection(&panel, connection_b, &mut cx);
5617
5618 let session_id_b = active_session_id(&panel, &cx);
5619
5620 panel.read_with(&cx, |panel, _cx| {
5621 assert_eq!(panel.background_threads.len(), 1);
5622 assert!(panel.background_threads.contains_key(&session_id_a));
5623 });
5624
5625 // Load thread A back via load_agent_thread — should promote from background.
5626 panel.update_in(&mut cx, |panel, window, cx| {
5627 panel.load_agent_thread(
5628 panel.selected_agent().expect("selected agent must be set"),
5629 session_id_a.clone(),
5630 None,
5631 None,
5632 true,
5633 window,
5634 cx,
5635 );
5636 });
5637
5638 // Thread A should now be the active view, promoted from background.
5639 let active_session = active_session_id(&panel, &cx);
5640 assert_eq!(
5641 active_session, session_id_a,
5642 "Thread A should be the active thread after promotion"
5643 );
5644
5645 panel.read_with(&cx, |panel, _cx| {
5646 assert!(
5647 !panel.background_threads.contains_key(&session_id_a),
5648 "Promoted thread A should no longer be in background_views"
5649 );
5650 assert!(
5651 panel.background_threads.contains_key(&session_id_b),
5652 "Thread B (idle, non-loadable) should remain retained in background_views"
5653 );
5654 });
5655 }
5656
5657 #[gpui::test]
5658 async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
5659 cx: &mut TestAppContext,
5660 ) {
5661 let (panel, mut cx) = setup_panel(cx).await;
5662 let connection = StubAgentConnection::new()
5663 .with_supports_load_session(true)
5664 .with_agent_id("loadable-stub".into())
5665 .with_telemetry_id("loadable-stub".into());
5666 let mut session_ids = Vec::new();
5667
5668 for _ in 0..7 {
5669 session_ids.push(open_generating_thread_with_loadable_connection(
5670 &panel,
5671 &connection,
5672 &mut cx,
5673 ));
5674 }
5675
5676 let base_time = Instant::now();
5677
5678 for session_id in session_ids.iter().take(6) {
5679 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5680 }
5681 cx.run_until_parked();
5682
5683 panel.update(&mut cx, |panel, cx| {
5684 for (index, session_id) in session_ids.iter().take(6).enumerate() {
5685 let conversation_view = panel
5686 .background_threads
5687 .get(session_id)
5688 .expect("background thread should exist")
5689 .clone();
5690 conversation_view.update(cx, |view, cx| {
5691 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5692 });
5693 }
5694 panel.cleanup_background_threads(cx);
5695 });
5696
5697 panel.read_with(&cx, |panel, _cx| {
5698 assert_eq!(
5699 panel.background_threads.len(),
5700 5,
5701 "cleanup should keep at most five idle loadable background threads"
5702 );
5703 assert!(
5704 !panel.background_threads.contains_key(&session_ids[0]),
5705 "oldest idle loadable background thread should be removed"
5706 );
5707 for session_id in &session_ids[1..6] {
5708 assert!(
5709 panel.background_threads.contains_key(session_id),
5710 "more recent idle loadable background threads should be retained"
5711 );
5712 }
5713 assert!(
5714 !panel.background_threads.contains_key(&session_ids[6]),
5715 "the active thread should not also be stored as a background thread"
5716 );
5717 });
5718 }
5719
5720 #[gpui::test]
5721 async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
5722 cx: &mut TestAppContext,
5723 ) {
5724 let (panel, mut cx) = setup_panel(cx).await;
5725
5726 let non_loadable_connection = StubAgentConnection::new();
5727 let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
5728 &panel,
5729 &non_loadable_connection,
5730 &mut cx,
5731 );
5732
5733 let loadable_connection = StubAgentConnection::new()
5734 .with_supports_load_session(true)
5735 .with_agent_id("loadable-stub".into())
5736 .with_telemetry_id("loadable-stub".into());
5737 let mut loadable_session_ids = Vec::new();
5738
5739 for _ in 0..7 {
5740 loadable_session_ids.push(open_generating_thread_with_loadable_connection(
5741 &panel,
5742 &loadable_connection,
5743 &mut cx,
5744 ));
5745 }
5746
5747 let base_time = Instant::now();
5748
5749 for session_id in loadable_session_ids.iter().take(6) {
5750 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5751 }
5752 cx.run_until_parked();
5753
5754 panel.update(&mut cx, |panel, cx| {
5755 for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
5756 let conversation_view = panel
5757 .background_threads
5758 .get(session_id)
5759 .expect("background thread should exist")
5760 .clone();
5761 conversation_view.update(cx, |view, cx| {
5762 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5763 });
5764 }
5765 panel.cleanup_background_threads(cx);
5766 });
5767
5768 panel.read_with(&cx, |panel, _cx| {
5769 assert_eq!(
5770 panel.background_threads.len(),
5771 6,
5772 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
5773 );
5774 assert!(
5775 panel
5776 .background_threads
5777 .contains_key(&non_loadable_session_id),
5778 "idle non-loadable background threads should not be cleanup candidates"
5779 );
5780 assert!(
5781 !panel
5782 .background_threads
5783 .contains_key(&loadable_session_ids[0]),
5784 "oldest idle loadable background thread should still be removed"
5785 );
5786 for session_id in &loadable_session_ids[1..6] {
5787 assert!(
5788 panel.background_threads.contains_key(session_id),
5789 "more recent idle loadable background threads should be retained"
5790 );
5791 }
5792 assert!(
5793 !panel
5794 .background_threads
5795 .contains_key(&loadable_session_ids[6]),
5796 "the active loadable thread should not also be stored as a background thread"
5797 );
5798 });
5799 }
5800
5801 #[gpui::test]
5802 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5803 init_test(cx);
5804 cx.update(|cx| {
5805 cx.update_flags(true, vec!["agent-v2".to_string()]);
5806 agent::ThreadStore::init_global(cx);
5807 language_model::LanguageModelRegistry::test(cx);
5808 });
5809
5810 let fs = FakeFs::new(cx.executor());
5811 fs.insert_tree(
5812 "/project",
5813 json!({
5814 ".git": {},
5815 "src": {
5816 "main.rs": "fn main() {}"
5817 }
5818 }),
5819 )
5820 .await;
5821 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5822
5823 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5824
5825 let multi_workspace =
5826 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5827
5828 let workspace = multi_workspace
5829 .read_with(cx, |multi_workspace, _cx| {
5830 multi_workspace.workspace().clone()
5831 })
5832 .unwrap();
5833
5834 workspace.update(cx, |workspace, _cx| {
5835 workspace.set_random_database_id();
5836 });
5837
5838 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5839
5840 // Wait for the project to discover the git repository.
5841 cx.run_until_parked();
5842
5843 let panel = workspace.update_in(cx, |workspace, window, cx| {
5844 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5845 let panel =
5846 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
5847 workspace.add_panel(panel.clone(), window, cx);
5848 panel
5849 });
5850
5851 cx.run_until_parked();
5852
5853 // Default thread target should be LocalProject.
5854 panel.read_with(cx, |panel, _cx| {
5855 assert_eq!(
5856 *panel.start_thread_in(),
5857 StartThreadIn::LocalProject,
5858 "default thread target should be LocalProject"
5859 );
5860 });
5861
5862 // Start a new thread with the default LocalProject target.
5863 // Use StubAgentServer so the thread connects immediately in tests.
5864 panel.update_in(cx, |panel, window, cx| {
5865 panel.open_external_thread_with_server(
5866 Rc::new(StubAgentServer::default_response()),
5867 window,
5868 cx,
5869 );
5870 });
5871
5872 cx.run_until_parked();
5873
5874 // MultiWorkspace should still have exactly one workspace (no worktree created).
5875 multi_workspace
5876 .read_with(cx, |multi_workspace, _cx| {
5877 assert_eq!(
5878 multi_workspace.workspaces().len(),
5879 1,
5880 "LocalProject should not create a new workspace"
5881 );
5882 })
5883 .unwrap();
5884
5885 // The thread should be active in the panel.
5886 panel.read_with(cx, |panel, cx| {
5887 assert!(
5888 panel.active_agent_thread(cx).is_some(),
5889 "a thread should be running in the current workspace"
5890 );
5891 });
5892
5893 // The thread target should still be LocalProject (unchanged).
5894 panel.read_with(cx, |panel, _cx| {
5895 assert_eq!(
5896 *panel.start_thread_in(),
5897 StartThreadIn::LocalProject,
5898 "thread target should remain LocalProject"
5899 );
5900 });
5901
5902 // No worktree creation status should be set.
5903 panel.read_with(cx, |panel, _cx| {
5904 assert!(
5905 panel.worktree_creation_status.is_none(),
5906 "no worktree creation should have occurred"
5907 );
5908 });
5909 }
5910
5911 #[gpui::test]
5912 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5913 init_test(cx);
5914 cx.update(|cx| {
5915 cx.update_flags(true, vec!["agent-v2".to_string()]);
5916 agent::ThreadStore::init_global(cx);
5917 language_model::LanguageModelRegistry::test(cx);
5918 });
5919
5920 let fs = FakeFs::new(cx.executor());
5921 fs.insert_tree(
5922 "/project",
5923 json!({
5924 ".git": {},
5925 "src": {
5926 "main.rs": "fn main() {}"
5927 }
5928 }),
5929 )
5930 .await;
5931 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5932
5933 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5934
5935 let multi_workspace =
5936 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5937
5938 let workspace = multi_workspace
5939 .read_with(cx, |multi_workspace, _cx| {
5940 multi_workspace.workspace().clone()
5941 })
5942 .unwrap();
5943
5944 workspace.update(cx, |workspace, _cx| {
5945 workspace.set_random_database_id();
5946 });
5947
5948 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5949
5950 // Wait for the project to discover the git repository.
5951 cx.run_until_parked();
5952
5953 let panel = workspace.update_in(cx, |workspace, window, cx| {
5954 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5955 let panel =
5956 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
5957 workspace.add_panel(panel.clone(), window, cx);
5958 panel
5959 });
5960
5961 cx.run_until_parked();
5962
5963 // Default should be LocalProject.
5964 panel.read_with(cx, |panel, _cx| {
5965 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5966 });
5967
5968 // Change thread target to NewWorktree.
5969 panel.update_in(cx, |panel, window, cx| {
5970 panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
5971 });
5972
5973 panel.read_with(cx, |panel, _cx| {
5974 assert_eq!(
5975 *panel.start_thread_in(),
5976 StartThreadIn::NewWorktree,
5977 "thread target should be NewWorktree after set_thread_target"
5978 );
5979 });
5980
5981 // Let serialization complete.
5982 cx.run_until_parked();
5983
5984 // Load a fresh panel from the serialized data.
5985 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
5986 let async_cx = cx.update(|window, cx| window.to_async(cx));
5987 let loaded_panel =
5988 AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx)
5989 .await
5990 .expect("panel load should succeed");
5991 cx.run_until_parked();
5992
5993 loaded_panel.read_with(cx, |panel, _cx| {
5994 assert_eq!(
5995 *panel.start_thread_in(),
5996 StartThreadIn::NewWorktree,
5997 "thread target should survive serialization round-trip"
5998 );
5999 });
6000 }
6001
6002 #[gpui::test]
6003 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
6004 init_test(cx);
6005
6006 let fs = FakeFs::new(cx.executor());
6007 cx.update(|cx| {
6008 cx.update_flags(true, vec!["agent-v2".to_string()]);
6009 agent::ThreadStore::init_global(cx);
6010 language_model::LanguageModelRegistry::test(cx);
6011 <dyn fs::Fs>::set_global(fs.clone(), cx);
6012 });
6013
6014 fs.insert_tree(
6015 "/project",
6016 json!({
6017 ".git": {},
6018 "src": {
6019 "main.rs": "fn main() {}"
6020 }
6021 }),
6022 )
6023 .await;
6024
6025 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6026
6027 let multi_workspace =
6028 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6029
6030 let workspace = multi_workspace
6031 .read_with(cx, |multi_workspace, _cx| {
6032 multi_workspace.workspace().clone()
6033 })
6034 .unwrap();
6035
6036 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6037
6038 let panel = workspace.update_in(cx, |workspace, window, cx| {
6039 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
6040 let panel =
6041 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
6042 workspace.add_panel(panel.clone(), window, cx);
6043 panel
6044 });
6045
6046 cx.run_until_parked();
6047
6048 // Simulate worktree creation in progress and reset to Uninitialized
6049 panel.update_in(cx, |panel, window, cx| {
6050 panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
6051 panel.active_view = ActiveView::Uninitialized;
6052 Panel::set_active(panel, true, window, cx);
6053 assert!(
6054 matches!(panel.active_view, ActiveView::Uninitialized),
6055 "set_active should not create a thread while worktree is being created"
6056 );
6057 });
6058
6059 // Clear the creation status and use open_external_thread_with_server
6060 // (which bypasses new_agent_thread) to verify the panel can transition
6061 // out of Uninitialized. We can't call set_active directly because
6062 // new_agent_thread requires full agent server infrastructure.
6063 panel.update_in(cx, |panel, window, cx| {
6064 panel.worktree_creation_status = None;
6065 panel.active_view = ActiveView::Uninitialized;
6066 panel.open_external_thread_with_server(
6067 Rc::new(StubAgentServer::default_response()),
6068 window,
6069 cx,
6070 );
6071 });
6072
6073 cx.run_until_parked();
6074
6075 panel.read_with(cx, |panel, _cx| {
6076 assert!(
6077 !matches!(panel.active_view, ActiveView::Uninitialized),
6078 "panel should transition out of Uninitialized once worktree creation is cleared"
6079 );
6080 });
6081 }
6082
6083 #[test]
6084 fn test_deserialize_agent_type_variants() {
6085 assert_eq!(
6086 serde_json::from_str::<AgentType>(r#""NativeAgent""#).unwrap(),
6087 AgentType::NativeAgent,
6088 );
6089 assert_eq!(
6090 serde_json::from_str::<AgentType>(r#""TextThread""#).unwrap(),
6091 AgentType::TextThread,
6092 );
6093 assert_eq!(
6094 serde_json::from_str::<AgentType>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
6095 AgentType::Custom {
6096 id: "my-agent".into(),
6097 },
6098 );
6099 }
6100
6101 #[gpui::test]
6102 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6103 init_test(cx);
6104
6105 let app_state = cx.update(|cx| {
6106 cx.update_flags(true, vec!["agent-v2".to_string()]);
6107 agent::ThreadStore::init_global(cx);
6108 language_model::LanguageModelRegistry::test(cx);
6109
6110 let app_state = workspace::AppState::test(cx);
6111 workspace::init(app_state.clone(), cx);
6112 app_state
6113 });
6114
6115 let fs = app_state.fs.as_fake();
6116 fs.insert_tree(
6117 "/project",
6118 json!({
6119 ".git": {},
6120 "src": {
6121 "main.rs": "fn main() {}"
6122 }
6123 }),
6124 )
6125 .await;
6126 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6127
6128 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6129
6130 let multi_workspace =
6131 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6132
6133 let workspace = multi_workspace
6134 .read_with(cx, |multi_workspace, _cx| {
6135 multi_workspace.workspace().clone()
6136 })
6137 .unwrap();
6138
6139 workspace.update(cx, |workspace, _cx| {
6140 workspace.set_random_database_id();
6141 });
6142
6143 // Register a callback so new workspaces also get an AgentPanel.
6144 cx.update(|cx| {
6145 cx.observe_new(
6146 |workspace: &mut Workspace,
6147 window: Option<&mut Window>,
6148 cx: &mut Context<Workspace>| {
6149 if let Some(window) = window {
6150 let project = workspace.project().clone();
6151 let text_thread_store =
6152 cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
6153 let panel = cx.new(|cx| {
6154 AgentPanel::new(workspace, text_thread_store, None, window, cx)
6155 });
6156 workspace.add_panel(panel, window, cx);
6157 }
6158 },
6159 )
6160 .detach();
6161 });
6162
6163 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6164
6165 // Wait for the project to discover the git repository.
6166 cx.run_until_parked();
6167
6168 let panel = workspace.update_in(cx, |workspace, window, cx| {
6169 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
6170 let panel =
6171 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
6172 workspace.add_panel(panel.clone(), window, cx);
6173 panel
6174 });
6175
6176 cx.run_until_parked();
6177
6178 // Open a thread (needed so there's an active thread view).
6179 panel.update_in(cx, |panel, window, cx| {
6180 panel.open_external_thread_with_server(
6181 Rc::new(StubAgentServer::default_response()),
6182 window,
6183 cx,
6184 );
6185 });
6186
6187 cx.run_until_parked();
6188
6189 // Set the selected agent to Codex (a custom agent) and start_thread_in
6190 // to NewWorktree. We do this AFTER opening the thread because
6191 // open_external_thread_with_server overrides selected_agent_type.
6192 panel.update_in(cx, |panel, window, cx| {
6193 panel.selected_agent_type = AgentType::Custom {
6194 id: CODEX_ID.into(),
6195 };
6196 panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
6197 });
6198
6199 // Verify the panel has the Codex agent selected.
6200 panel.read_with(cx, |panel, _cx| {
6201 assert_eq!(
6202 panel.selected_agent_type,
6203 AgentType::Custom {
6204 id: CODEX_ID.into()
6205 },
6206 );
6207 });
6208
6209 // Directly call handle_worktree_creation_requested, which is what
6210 // handle_first_send_requested does when start_thread_in == NewWorktree.
6211 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6212 "Hello from test",
6213 ))];
6214 panel.update_in(cx, |panel, window, cx| {
6215 panel.handle_worktree_creation_requested(content, window, cx);
6216 });
6217
6218 // Let the async worktree creation + workspace setup complete.
6219 cx.run_until_parked();
6220
6221 // Find the new workspace's AgentPanel and verify it used the Codex agent.
6222 let found_codex = multi_workspace
6223 .read_with(cx, |multi_workspace, cx| {
6224 // There should be more than one workspace now (the original + the new worktree).
6225 assert!(
6226 multi_workspace.workspaces().len() > 1,
6227 "expected a new workspace to have been created, found {}",
6228 multi_workspace.workspaces().len(),
6229 );
6230
6231 // Check the newest workspace's panel for the correct agent.
6232 let new_workspace = multi_workspace
6233 .workspaces()
6234 .iter()
6235 .find(|ws| ws.entity_id() != workspace.entity_id())
6236 .expect("should find the new workspace");
6237 let new_panel = new_workspace
6238 .read(cx)
6239 .panel::<AgentPanel>(cx)
6240 .expect("new workspace should have an AgentPanel");
6241
6242 new_panel.read(cx).selected_agent_type.clone()
6243 })
6244 .unwrap();
6245
6246 assert_eq!(
6247 found_codex,
6248 AgentType::Custom {
6249 id: CODEX_ID.into()
6250 },
6251 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6252 );
6253 }
6254}