1use std::{
2 path::PathBuf,
3 rc::Rc,
4 sync::{
5 Arc,
6 atomic::{AtomicBool, Ordering},
7 },
8 time::Duration,
9};
10
11use acp_thread::{AcpThread, MentionUri, ThreadStatus};
12use agent::{ContextServerRegistry, SharedThread, ThreadStore};
13use agent_client_protocol as acp;
14use agent_servers::AgentServer;
15use collections::HashSet;
16use db::kvp::{Dismissable, KeyValueStore};
17use itertools::Itertools;
18use project::AgentId;
19use serde::{Deserialize, Serialize};
20use settings::{LanguageModelProviderSetting, LanguageModelSelection};
21
22use zed_actions::agent::{
23 AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
24 ResolveConflictsWithAgent, ReviewBranchDiff,
25};
26
27use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
28use crate::{
29 AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
30 Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
31 OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
32 StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
33 agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
34 conversation_view::{AcpThreadViewEvent, ThreadView},
35 thread_branch_picker::ThreadBranchPicker,
36 thread_worktree_picker::ThreadWorktreePicker,
37 ui::EndTrialUpsell,
38};
39use crate::{
40 Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
41 NewNativeAgentThreadFromSummary,
42};
43use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
44use crate::{ExpandMessageEditor, ThreadHistoryView};
45use crate::{ManageProfiles, ThreadHistoryViewEvent};
46use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
47use agent_settings::{AgentSettings, WindowLayout};
48use ai_onboarding::AgentPanelOnboarding;
49use anyhow::{Context as _, Result, anyhow};
50use client::UserStore;
51use cloud_api_types::Plan;
52use collections::HashMap;
53use editor::Editor;
54use extension::ExtensionEvents;
55use extension_host::ExtensionStore;
56use fs::Fs;
57use gpui::{
58 Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
59 DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable,
60 KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
61 pulsating_between,
62};
63use language::LanguageRegistry;
64use language_model::LanguageModelRegistry;
65use project::git_store::{GitStoreEvent, RepositoryEvent};
66use project::project_settings::ProjectSettings;
67use project::{Project, ProjectPath, Worktree, WorktreePaths, linked_worktree_short_name};
68use prompt_store::{PromptStore, UserPromptId};
69use remote::RemoteConnectionOptions;
70use rules_library::{RulesLibrary, open_rules_library};
71use settings::TerminalDockPosition;
72use settings::{Settings, update_settings_file};
73use terminal::terminal_settings::TerminalSettings;
74use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
75use theme_settings::ThemeSettings;
76use ui::{
77 Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
78 PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
79};
80use util::{ResultExt as _, debug_panic};
81use workspace::{
82 CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
83 ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, WorkspaceSidebarDelegate,
84 dock::{DockPosition, Panel, PanelEvent},
85};
86use zed_actions::{
87 DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
88 agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
89 assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
90};
91
92const AGENT_PANEL_KEY: &str = "agent_panel";
93const MIN_PANEL_WIDTH: Pixels = px(300.);
94const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
95const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
96/// Maximum number of idle threads kept in the agent panel's retained list.
97/// Set as a GPUI global to override; otherwise defaults to 5.
98pub struct MaxIdleRetainedThreads(pub usize);
99impl gpui::Global for MaxIdleRetainedThreads {}
100
101impl MaxIdleRetainedThreads {
102 pub fn global(cx: &App) -> usize {
103 cx.try_global::<Self>().map_or(5, |g| g.0)
104 }
105}
106
107#[derive(Default)]
108struct AgentPanelSidebarDelegate;
109
110impl WorkspaceSidebarDelegate for AgentPanelSidebarDelegate {
111 fn reconcile_group(
112 &self,
113 workspace: &mut Workspace,
114 group_key: &workspace::ProjectGroupKey,
115 window: &mut Window,
116 cx: &mut Context<Workspace>,
117 ) -> bool {
118 if workspace.project_group_key(cx) != *group_key {
119 return false;
120 }
121
122 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
123 return false;
124 };
125
126 panel.update(cx, |panel, cx| {
127 if panel.pending_thread_loads > 0 {
128 return false;
129 }
130 if panel.draft_thread_ids(cx).is_empty() {
131 panel.create_thread(window, cx);
132 true
133 } else {
134 false
135 }
136 })
137 }
138}
139
140#[derive(Serialize, Deserialize)]
141struct LastUsedAgent {
142 agent: Agent,
143}
144
145/// Reads the most recently used agent across all workspaces. Used as a fallback
146/// when opening a workspace that has no per-workspace agent preference yet.
147fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option<Agent> {
148 kvp.read_kvp(LAST_USED_AGENT_KEY)
149 .log_err()
150 .flatten()
151 .and_then(|json| serde_json::from_str::<LastUsedAgent>(&json).log_err())
152 .map(|entry| entry.agent)
153}
154
155async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) {
156 if let Some(json) = serde_json::to_string(&LastUsedAgent { agent }).log_err() {
157 kvp.write_kvp(LAST_USED_AGENT_KEY.to_string(), json)
158 .await
159 .log_err();
160 }
161}
162
163fn read_serialized_panel(
164 workspace_id: workspace::WorkspaceId,
165 kvp: &KeyValueStore,
166) -> Option<SerializedAgentPanel> {
167 let scope = kvp.scoped(AGENT_PANEL_KEY);
168 let key = i64::from(workspace_id).to_string();
169 scope
170 .read(&key)
171 .log_err()
172 .flatten()
173 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
174}
175
176async fn save_serialized_panel(
177 workspace_id: workspace::WorkspaceId,
178 panel: SerializedAgentPanel,
179 kvp: KeyValueStore,
180) -> Result<()> {
181 let scope = kvp.scoped(AGENT_PANEL_KEY);
182 let key = i64::from(workspace_id).to_string();
183 scope.write(key, serde_json::to_string(&panel)?).await?;
184 Ok(())
185}
186
187/// Migration: reads the original single-panel format stored under the
188/// `"agent_panel"` KVP key before per-workspace keying was introduced.
189fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPanel> {
190 kvp.read_kvp(AGENT_PANEL_KEY)
191 .log_err()
192 .flatten()
193 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
194}
195
196#[derive(Serialize, Deserialize, Debug)]
197struct SerializedAgentPanel {
198 selected_agent: Option<Agent>,
199 #[serde(default)]
200 last_active_thread: Option<SerializedActiveThread>,
201 #[serde(default)]
202 start_thread_in: Option<StartThreadIn>,
203}
204
205#[derive(Serialize, Deserialize, Debug)]
206struct SerializedActiveThread {
207 session_id: String,
208 agent_type: Agent,
209 title: Option<String>,
210 work_dirs: Option<SerializedPathList>,
211}
212
213pub fn init(cx: &mut App) {
214 cx.observe_new(
215 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
216 workspace.set_sidebar_delegate(Arc::new(AgentPanelSidebarDelegate));
217 workspace
218 .register_action(|workspace, action: &NewThread, window, cx| {
219 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
220 panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
221 workspace.focus_panel::<AgentPanel>(window, cx);
222 }
223 })
224 .register_action(
225 |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
226 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
227 panel.update(cx, |panel, cx| {
228 panel.new_native_agent_thread_from_summary(action, window, cx)
229 });
230 workspace.focus_panel::<AgentPanel>(window, cx);
231 }
232 },
233 )
234 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
235 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
236 workspace.focus_panel::<AgentPanel>(window, cx);
237 panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
238 }
239 })
240 .register_action(|workspace, _: &OpenHistory, window, cx| {
241 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
242 workspace.focus_panel::<AgentPanel>(window, cx);
243 panel.update(cx, |panel, cx| panel.open_history(window, cx));
244 }
245 })
246 .register_action(|workspace, _: &OpenSettings, window, cx| {
247 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
248 workspace.focus_panel::<AgentPanel>(window, cx);
249 panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
250 }
251 })
252 .register_action(|workspace, _action: &NewExternalAgentThread, window, cx| {
253 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
254 workspace.focus_panel::<AgentPanel>(window, cx);
255 panel.update(cx, |panel, cx| {
256 let id = panel.create_thread(window, cx);
257 panel.activate_retained_thread(id, true, window, cx);
258 });
259 }
260 })
261 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
262 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
263 workspace.focus_panel::<AgentPanel>(window, cx);
264 panel.update(cx, |panel, cx| {
265 panel.deploy_rules_library(action, window, cx)
266 });
267 }
268 })
269 .register_action(|workspace, _: &Follow, window, cx| {
270 workspace.follow(CollaboratorId::Agent, window, cx);
271 })
272 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
273 let thread = workspace
274 .panel::<AgentPanel>(cx)
275 .and_then(|panel| panel.read(cx).active_conversation_view().cloned())
276 .and_then(|conversation| {
277 conversation
278 .read(cx)
279 .active_thread()
280 .map(|r| r.read(cx).thread.clone())
281 });
282
283 if let Some(thread) = thread {
284 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
285 }
286 })
287 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
288 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
289 workspace.focus_panel::<AgentPanel>(window, cx);
290 panel.update(cx, |panel, cx| {
291 panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
292 });
293 }
294 })
295 .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
296 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
297 workspace.focus_panel::<AgentPanel>(window, cx);
298 panel.update(cx, |panel, cx| {
299 panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
300 });
301 }
302 })
303 .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
304 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
305 workspace.focus_panel::<AgentPanel>(window, cx);
306 panel.update(cx, |panel, cx| {
307 panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
308 });
309 }
310 })
311 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
312 AcpOnboardingModal::toggle(workspace, window, cx)
313 })
314 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
315 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
316 window.refresh();
317 })
318 .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
319 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
320 panel.update(cx, |panel, _| {
321 panel
322 .new_user_onboarding_upsell_dismissed
323 .store(false, Ordering::Release);
324 });
325 }
326 OnboardingUpsell::set_dismissed(false, cx);
327 })
328 .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
329 TrialEndUpsell::set_dismissed(false, cx);
330 })
331 .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
332 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
333 panel.update(cx, |panel, cx| {
334 panel.reset_agent_zoom(window, cx);
335 });
336 }
337 })
338 .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
339 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
340 panel.update(cx, |panel, cx| {
341 panel.copy_thread_to_clipboard(window, cx);
342 });
343 }
344 })
345 .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
346 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
347 workspace.focus_panel::<AgentPanel>(window, cx);
348 panel.update(cx, |panel, cx| {
349 panel.load_thread_from_clipboard(window, cx);
350 });
351 }
352 })
353 .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
354 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
355 return;
356 };
357
358 let mention_uri = MentionUri::GitDiff {
359 base_ref: action.base_ref.to_string(),
360 };
361 let diff_uri = mention_uri.to_uri().to_string();
362
363 let content_blocks = vec![
364 acp::ContentBlock::Text(acp::TextContent::new(
365 "Please review this branch diff carefully. Point out any issues, \
366 potential bugs, or improvement opportunities you find.\n\n"
367 .to_string(),
368 )),
369 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
370 acp::EmbeddedResourceResource::TextResourceContents(
371 acp::TextResourceContents::new(
372 action.diff_text.to_string(),
373 diff_uri,
374 ),
375 ),
376 )),
377 ];
378
379 workspace.focus_panel::<AgentPanel>(window, cx);
380
381 panel.update(cx, |panel, cx| {
382 panel.external_thread(
383 None,
384 None,
385 None,
386 None,
387 Some(AgentInitialContent::ContentBlock {
388 blocks: content_blocks,
389 auto_submit: true,
390 }),
391 true,
392 window,
393 cx,
394 );
395 });
396 })
397 .register_action(
398 |workspace, action: &ResolveConflictsWithAgent, window, cx| {
399 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
400 return;
401 };
402
403 let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
404
405 workspace.focus_panel::<AgentPanel>(window, cx);
406
407 panel.update(cx, |panel, cx| {
408 panel.external_thread(
409 None,
410 None,
411 None,
412 None,
413 Some(AgentInitialContent::ContentBlock {
414 blocks: content_blocks,
415 auto_submit: true,
416 }),
417 true,
418 window,
419 cx,
420 );
421 });
422 },
423 )
424 .register_action(
425 |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
426 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
427 return;
428 };
429
430 let content_blocks =
431 build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
432
433 workspace.focus_panel::<AgentPanel>(window, cx);
434
435 panel.update(cx, |panel, cx| {
436 panel.external_thread(
437 None,
438 None,
439 None,
440 None,
441 Some(AgentInitialContent::ContentBlock {
442 blocks: content_blocks,
443 auto_submit: true,
444 }),
445 true,
446 window,
447 cx,
448 );
449 });
450 },
451 )
452 .register_action(|workspace, action: &StartThreadIn, window, cx| {
453 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
454 panel.update(cx, |panel, cx| {
455 panel.set_start_thread_in(action, window, cx);
456 });
457 }
458 })
459 .register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
460 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
461 panel.update(cx, |panel, cx| {
462 panel.cycle_start_thread_in(window, cx);
463 });
464 }
465 })
466 .register_action(
467 |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
468 let active_editor = workspace
469 .active_item(cx)
470 .and_then(|item| item.act_as::<Editor>(cx));
471 let has_editor_selection = active_editor.is_some_and(|editor| {
472 editor.update(cx, |editor, cx| {
473 editor.has_non_empty_selection(&editor.display_snapshot(cx))
474 })
475 });
476
477 let has_terminal_selection = workspace
478 .active_item(cx)
479 .and_then(|item| item.act_as::<TerminalView>(cx))
480 .is_some_and(|terminal_view| {
481 terminal_view
482 .read(cx)
483 .terminal()
484 .read(cx)
485 .last_content
486 .selection_text
487 .as_ref()
488 .is_some_and(|text| !text.is_empty())
489 });
490
491 let has_terminal_panel_selection =
492 workspace.panel::<TerminalPanel>(cx).is_some_and(|panel| {
493 let position = match TerminalSettings::get_global(cx).dock {
494 TerminalDockPosition::Left => DockPosition::Left,
495 TerminalDockPosition::Bottom => DockPosition::Bottom,
496 TerminalDockPosition::Right => DockPosition::Right,
497 };
498 let dock_is_open =
499 workspace.dock_at_position(position).read(cx).is_open();
500 dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty()
501 });
502
503 if !has_editor_selection
504 && !has_terminal_selection
505 && !has_terminal_panel_selection
506 {
507 return;
508 }
509
510 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
511 return;
512 };
513
514 if !panel.focus_handle(cx).contains_focused(window, cx) {
515 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
516 }
517
518 panel.update(cx, |_, cx| {
519 cx.defer_in(window, move |panel, window, cx| {
520 if let Some(conversation_view) = panel.active_conversation_view() {
521 conversation_view.update(cx, |conversation_view, cx| {
522 conversation_view.insert_selections(window, cx);
523 });
524 }
525 });
526 });
527 },
528 );
529 },
530 )
531 .detach();
532}
533
534fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
535 let mention_uri = MentionUri::MergeConflict {
536 file_path: conflict.file_path.clone(),
537 };
538 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
539 acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
540 conflict.conflict_text.clone(),
541 mention_uri.to_uri().to_string(),
542 )),
543 ))
544}
545
546fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
547 if conflicts.is_empty() {
548 return Vec::new();
549 }
550
551 let mut blocks = Vec::new();
552
553 if conflicts.len() == 1 {
554 let conflict = &conflicts[0];
555
556 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
557 "Please resolve the following merge conflict in ",
558 )));
559 let mention = MentionUri::File {
560 abs_path: PathBuf::from(conflict.file_path.clone()),
561 };
562 blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
563 mention.name(),
564 mention.to_uri(),
565 )));
566
567 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
568 indoc::formatdoc!(
569 "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
570
571 Analyze both versions carefully and resolve the conflict by editing \
572 the file directly. Choose the resolution that best preserves the intent \
573 of both changes, or combine them if appropriate.
574
575 ",
576 ours = conflict.ours_branch_name,
577 theirs = conflict.theirs_branch_name,
578 ),
579 )));
580 } else {
581 let n = conflicts.len();
582 let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
583 let ours = &conflicts[0].ours_branch_name;
584 let theirs = &conflicts[0].theirs_branch_name;
585 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
586 indoc::formatdoc!(
587 "Please resolve all {n} merge conflicts below.
588
589 The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
590
591 For each conflict, analyze both versions carefully and resolve them \
592 by editing the file{suffix} directly. Choose resolutions that best preserve \
593 the intent of both changes, or combine them if appropriate.
594
595 ",
596 suffix = if unique_files.len() > 1 { "s" } else { "" },
597 ),
598 )));
599 }
600
601 for conflict in conflicts {
602 blocks.push(conflict_resource_block(conflict));
603 }
604
605 blocks
606}
607
608fn build_conflicted_files_resolution_prompt(
609 conflicted_file_paths: &[String],
610) -> Vec<acp::ContentBlock> {
611 if conflicted_file_paths.is_empty() {
612 return Vec::new();
613 }
614
615 let instruction = indoc::indoc!(
616 "The following files have unresolved merge conflicts. Please open each \
617 file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
618 and resolve every conflict by editing the files directly.
619
620 Choose resolutions that best preserve the intent of both changes, \
621 or combine them if appropriate.
622
623 Files with conflicts:
624 ",
625 );
626
627 let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
628 for path in conflicted_file_paths {
629 let mention = MentionUri::File {
630 abs_path: PathBuf::from(path),
631 };
632 content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
633 mention.name(),
634 mention.to_uri(),
635 )));
636 content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
637 }
638 content
639}
640
641pub(crate) struct AgentThread {
642 conversation_view: Entity<ConversationView>,
643}
644
645enum BaseView {
646 Uninitialized,
647 AgentThread {
648 conversation_view: Entity<ConversationView>,
649 },
650}
651
652impl From<AgentThread> for BaseView {
653 fn from(thread: AgentThread) -> Self {
654 BaseView::AgentThread {
655 conversation_view: thread.conversation_view,
656 }
657 }
658}
659
660enum OverlayView {
661 History { view: Entity<ThreadHistoryView> },
662 Configuration,
663}
664
665enum VisibleSurface<'a> {
666 Uninitialized,
667 AgentThread(&'a Entity<ConversationView>),
668 History(&'a Entity<ThreadHistoryView>),
669 Configuration(Option<&'a Entity<AgentConfiguration>>),
670}
671
672enum WhichFontSize {
673 AgentFont,
674 None,
675}
676
677struct StartThreadInLabel {
678 prefix: Option<SharedString>,
679 label: SharedString,
680 suffix: Option<SharedString>,
681}
682
683impl StartThreadIn {
684 fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
685 match self {
686 Self::LocalProject => {
687 let suffix = project.active_repository(cx).and_then(|repo| {
688 let repo = repo.read(cx);
689 let work_dir = &repo.original_repo_abs_path;
690 let visible_paths: Vec<_> = project
691 .visible_worktrees(cx)
692 .map(|wt| wt.read(cx).abs_path().to_path_buf())
693 .collect();
694
695 for linked in repo.linked_worktrees() {
696 if visible_paths.contains(&linked.path) {
697 return Some(SharedString::from(format!(
698 "({})",
699 linked.display_name()
700 )));
701 }
702 }
703
704 if let Some(name) = linked_worktree_short_name(
705 repo.original_repo_abs_path.as_ref(),
706 repo.work_directory_abs_path.as_ref(),
707 ) {
708 if visible_paths
709 .iter()
710 .any(|p| p.as_path() == repo.work_directory_abs_path.as_ref())
711 {
712 return Some(SharedString::from(format!("({})", name)));
713 }
714 }
715
716 if visible_paths
717 .iter()
718 .any(|p| p.as_path() == work_dir.as_ref())
719 {
720 return Some("(main)".into());
721 }
722
723 None
724 });
725
726 StartThreadInLabel {
727 prefix: None,
728 label: "Current Worktree".into(),
729 suffix,
730 }
731 }
732 Self::NewWorktree {
733 worktree_name: Some(worktree_name),
734 ..
735 } => StartThreadInLabel {
736 prefix: Some("New:".into()),
737 label: worktree_name.clone().into(),
738 suffix: None,
739 },
740 Self::NewWorktree { .. } => StartThreadInLabel {
741 prefix: None,
742 label: "New Git Worktree".into(),
743 suffix: None,
744 },
745 Self::LinkedWorktree { display_name, .. } => StartThreadInLabel {
746 prefix: Some("From:".into()),
747 label: display_name.clone().into(),
748 suffix: None,
749 },
750 }
751 }
752
753 fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
754 match self {
755 Self::NewWorktree { branch_target, .. } => {
756 let (branch_name, is_occupied) = match branch_target {
757 NewWorktreeBranchTarget::CurrentBranch => {
758 let name: SharedString = if project.repositories(cx).len() > 1 {
759 "current branches".into()
760 } else {
761 project
762 .active_repository(cx)
763 .and_then(|repo| {
764 repo.read(cx)
765 .branch
766 .as_ref()
767 .map(|branch| SharedString::from(branch.name().to_string()))
768 })
769 .unwrap_or_else(|| "HEAD".into())
770 };
771 (name, false)
772 }
773 NewWorktreeBranchTarget::ExistingBranch { name } => {
774 let occupied = Self::is_branch_occupied(name, project, cx);
775 (name.clone().into(), occupied)
776 }
777 NewWorktreeBranchTarget::CreateBranch {
778 from_ref: Some(from_ref),
779 ..
780 } => {
781 let occupied = Self::is_branch_occupied(from_ref, project, cx);
782 (from_ref.clone().into(), occupied)
783 }
784 NewWorktreeBranchTarget::CreateBranch { name, .. } => {
785 (name.clone().into(), false)
786 }
787 };
788
789 let prefix = if is_occupied {
790 Some("New From:".into())
791 } else {
792 None
793 };
794
795 Some(StartThreadInLabel {
796 prefix,
797 label: branch_name,
798 suffix: None,
799 })
800 }
801 _ => None,
802 }
803 }
804
805 fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool {
806 project.repositories(cx).values().any(|repo| {
807 repo.read(cx)
808 .linked_worktrees
809 .iter()
810 .any(|wt| wt.branch_name() == Some(branch_name))
811 })
812 }
813}
814
815#[derive(Clone, Debug)]
816#[allow(dead_code)]
817pub enum WorktreeCreationStatus {
818 Creating,
819 Error(SharedString),
820}
821
822#[derive(Clone, Debug)]
823enum WorktreeCreationArgs {
824 New {
825 worktree_name: Option<String>,
826 branch_target: NewWorktreeBranchTarget,
827 },
828 Linked {
829 worktree_path: PathBuf,
830 },
831}
832
833impl BaseView {
834 pub fn which_font_size_used(&self) -> WhichFontSize {
835 WhichFontSize::AgentFont
836 }
837}
838
839impl OverlayView {
840 pub fn which_font_size_used(&self) -> WhichFontSize {
841 match self {
842 OverlayView::History { .. } => WhichFontSize::AgentFont,
843 OverlayView::Configuration => WhichFontSize::None,
844 }
845 }
846}
847
848pub struct AgentPanel {
849 workspace: WeakEntity<Workspace>,
850 /// Workspace id is used as a database key
851 workspace_id: Option<WorkspaceId>,
852 user_store: Entity<UserStore>,
853 project: Entity<Project>,
854 fs: Arc<dyn Fs>,
855 language_registry: Arc<LanguageRegistry>,
856 thread_store: Entity<ThreadStore>,
857 prompt_store: Option<Entity<PromptStore>>,
858 connection_store: Entity<AgentConnectionStore>,
859 context_server_registry: Entity<ContextServerRegistry>,
860 configuration: Option<Entity<AgentConfiguration>>,
861 configuration_subscription: Option<Subscription>,
862 focus_handle: FocusHandle,
863 base_view: BaseView,
864 overlay_view: Option<OverlayView>,
865 retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
866 new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
867 start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
868 thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
869 agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
870 agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
871 agent_navigation_menu: Option<Entity<ContextMenu>>,
872 _extension_subscription: Option<Subscription>,
873 _project_subscription: Subscription,
874 _git_store_subscription: Subscription,
875 zoomed: bool,
876 pending_serialization: Option<Task<Result<()>>>,
877 new_user_onboarding: Entity<AgentPanelOnboarding>,
878 new_user_onboarding_upsell_dismissed: AtomicBool,
879 agent_layout_onboarding: Entity<ai_onboarding::AgentLayoutOnboarding>,
880 agent_layout_onboarding_dismissed: AtomicBool,
881 selected_agent: Agent,
882 start_thread_in: StartThreadIn,
883 pending_thread_loads: usize,
884 worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
885 _thread_view_subscription: Option<Subscription>,
886 _active_thread_focus_subscription: Option<Subscription>,
887 _worktree_creation_task: Option<Task<()>>,
888 show_trust_workspace_message: bool,
889 _base_view_observation: Option<Subscription>,
890}
891
892impl AgentPanel {
893 fn serialize(&mut self, cx: &mut App) {
894 let Some(workspace_id) = self.workspace_id else {
895 return;
896 };
897
898 let selected_agent = self.selected_agent.clone();
899 let start_thread_in = Some(self.start_thread_in.clone());
900
901 let last_active_thread = self.active_agent_thread(cx).map(|thread| {
902 let thread = thread.read(cx);
903 let title = thread.title();
904 let work_dirs = thread.work_dirs().cloned();
905 SerializedActiveThread {
906 session_id: thread.session_id().0.to_string(),
907 agent_type: self.selected_agent.clone(),
908 title: title.map(|t| t.to_string()),
909 work_dirs: work_dirs.map(|dirs| dirs.serialize()),
910 }
911 });
912
913 let kvp = KeyValueStore::global(cx);
914 self.pending_serialization = Some(cx.background_spawn(async move {
915 save_serialized_panel(
916 workspace_id,
917 SerializedAgentPanel {
918 selected_agent: Some(selected_agent),
919 last_active_thread,
920 start_thread_in,
921 },
922 kvp,
923 )
924 .await?;
925 anyhow::Ok(())
926 }));
927 }
928
929 pub fn load(
930 workspace: WeakEntity<Workspace>,
931 mut cx: AsyncWindowContext,
932 ) -> Task<Result<Entity<Self>>> {
933 let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
934 let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
935 cx.spawn(async move |cx| {
936 let prompt_store = match prompt_store {
937 Ok(prompt_store) => prompt_store.await.ok(),
938 Err(_) => None,
939 };
940 let workspace_id = workspace
941 .read_with(cx, |workspace, _| workspace.database_id())
942 .ok()
943 .flatten();
944
945 let (serialized_panel, global_last_used_agent) = cx
946 .background_spawn(async move {
947 match kvp {
948 Some(kvp) => {
949 let panel = workspace_id
950 .and_then(|id| read_serialized_panel(id, &kvp))
951 .or_else(|| read_legacy_serialized_panel(&kvp));
952 let global_agent = read_global_last_used_agent(&kvp);
953 (panel, global_agent)
954 }
955 None => (None, None),
956 }
957 })
958 .await;
959
960 let last_active_thread = if let Some(thread_info) = serialized_panel
961 .as_ref()
962 .and_then(|p| p.last_active_thread.as_ref())
963 {
964 let session_id = acp::SessionId::new(thread_info.session_id.clone());
965 let is_restorable = cx
966 .update(|_window, cx| {
967 let store = ThreadMetadataStore::global(cx);
968 store
969 .read(cx)
970 .entry_by_session(&session_id)
971 .is_some_and(|entry| !entry.archived)
972 })
973 .unwrap_or(false);
974 if is_restorable {
975 Some(thread_info)
976 } else {
977 log::info!(
978 "last active thread {} is archived or missing, skipping restoration",
979 thread_info.session_id
980 );
981 None
982 }
983 } else {
984 None
985 };
986
987 let panel = workspace.update_in(cx, |workspace, window, cx| {
988 let panel =
989 cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
990
991 panel.update(cx, |panel, cx| {
992 let is_via_collab = panel.project.read(cx).is_via_collab();
993
994 // Only apply a non-native global fallback to local projects.
995 // Collab workspaces only support NativeAgent, so inheriting a
996 // custom agent would cause set_active → new_agent_thread_inner
997 // to bypass the collab guard in external_thread.
998 let global_fallback = global_last_used_agent
999 .filter(|agent| !is_via_collab || agent.is_native());
1000
1001 if let Some(serialized_panel) = &serialized_panel {
1002 if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
1003 panel.selected_agent = selected_agent;
1004 } else if let Some(agent) = global_fallback {
1005 panel.selected_agent = agent;
1006 }
1007 if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
1008 let is_valid = match &start_thread_in {
1009 StartThreadIn::LocalProject => true,
1010 StartThreadIn::NewWorktree { .. } => {
1011 let project = panel.project.read(cx);
1012 !project.is_via_collab()
1013 }
1014 StartThreadIn::LinkedWorktree { path, .. } => {
1015 path.exists()
1016 }
1017 };
1018 if is_valid {
1019 panel.start_thread_in = start_thread_in.clone();
1020 } else {
1021 log::info!(
1022 "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
1023 start_thread_in,
1024 );
1025 }
1026 }
1027 } else if let Some(agent) = global_fallback {
1028 panel.selected_agent = agent;
1029 }
1030 cx.notify();
1031 });
1032
1033 if let Some(thread_info) = last_active_thread {
1034 let agent = thread_info.agent_type.clone();
1035 panel.update(cx, |panel, cx| {
1036 panel.selected_agent = agent.clone();
1037 panel.load_agent_thread(
1038 agent,
1039 thread_info.session_id.clone().into(),
1040 thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
1041 thread_info.title.as_ref().map(|t| t.clone().into()),
1042 false,
1043 window,
1044 cx,
1045 );
1046 });
1047 }
1048 panel
1049 })?;
1050
1051 Ok(panel)
1052 })
1053 }
1054
1055 pub(crate) fn new(
1056 workspace: &Workspace,
1057 prompt_store: Option<Entity<PromptStore>>,
1058 window: &mut Window,
1059 cx: &mut Context<Self>,
1060 ) -> Self {
1061 let fs = workspace.app_state().fs.clone();
1062 let user_store = workspace.app_state().user_store.clone();
1063 let project = workspace.project();
1064 let language_registry = project.read(cx).languages().clone();
1065 let client = workspace.client().clone();
1066 let workspace_id = workspace.database_id();
1067 let workspace = workspace.weak_handle();
1068
1069 let context_server_registry =
1070 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1071
1072 let thread_store = ThreadStore::global(cx);
1073
1074 let base_view = BaseView::Uninitialized;
1075
1076 let weak_panel = cx.entity().downgrade();
1077
1078 window.defer(cx, move |window, cx| {
1079 let panel = weak_panel.clone();
1080 let agent_navigation_menu =
1081 ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
1082 if let Some(panel) = panel.upgrade() {
1083 if let Some(history) = panel
1084 .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
1085 {
1086 menu = Self::populate_recently_updated_menu_section(
1087 menu, panel, history, cx,
1088 );
1089 menu = menu.action("View All", Box::new(OpenHistory));
1090 }
1091 }
1092
1093 menu = menu
1094 .fixed_width(px(320.).into())
1095 .keep_open_on_confirm(false)
1096 .key_context("NavigationMenu");
1097
1098 menu
1099 });
1100 weak_panel
1101 .update(cx, |panel, cx| {
1102 cx.subscribe_in(
1103 &agent_navigation_menu,
1104 window,
1105 |_, menu, _: &DismissEvent, window, cx| {
1106 menu.update(cx, |menu, _| {
1107 menu.clear_selected();
1108 });
1109 cx.focus_self(window);
1110 },
1111 )
1112 .detach();
1113 panel.agent_navigation_menu = Some(agent_navigation_menu);
1114 })
1115 .ok();
1116 });
1117
1118 let weak_panel = cx.entity().downgrade();
1119 let onboarding = cx.new(|cx| {
1120 AgentPanelOnboarding::new(
1121 user_store.clone(),
1122 client,
1123 move |_window, cx| {
1124 weak_panel
1125 .update(cx, |panel, cx| {
1126 panel.dismiss_ai_onboarding(cx);
1127 })
1128 .ok();
1129 },
1130 cx,
1131 )
1132 });
1133
1134 let weak_panel = cx.entity().downgrade();
1135
1136 let layout = AgentSettings::get_layout(cx);
1137 let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
1138
1139 let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
1140 use_agent_layout: Arc::new({
1141 let fs = fs.clone();
1142 let weak_panel = weak_panel.clone();
1143 move |_window, cx| {
1144 AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
1145 weak_panel
1146 .update(cx, |panel, cx| {
1147 panel.dismiss_agent_layout_onboarding(cx);
1148 })
1149 .ok();
1150 }
1151 }),
1152 revert_to_editor_layout: Arc::new({
1153 let fs = fs.clone();
1154 let weak_panel = weak_panel.clone();
1155 move |_window, cx| {
1156 AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
1157 weak_panel
1158 .update(cx, |panel, cx| {
1159 panel.dismiss_agent_layout_onboarding(cx);
1160 })
1161 .ok();
1162 }
1163 }),
1164 dismissed: Arc::new(move |_window, cx| {
1165 weak_panel
1166 .update(cx, |panel, cx| {
1167 panel.dismiss_agent_layout_onboarding(cx);
1168 })
1169 .ok();
1170 }),
1171 is_agent_layout,
1172 });
1173
1174 // Subscribe to extension events to sync agent servers when extensions change
1175 let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
1176 {
1177 Some(
1178 cx.subscribe(&extension_events, |this, _source, event, cx| match event {
1179 extension::Event::ExtensionInstalled(_)
1180 | extension::Event::ExtensionUninstalled(_)
1181 | extension::Event::ExtensionsInstalledChanged => {
1182 this.sync_agent_servers_from_extensions(cx);
1183 }
1184 _ => {}
1185 }),
1186 )
1187 } else {
1188 None
1189 };
1190
1191 let connection_store = cx.new(|cx| {
1192 let mut store = AgentConnectionStore::new(project.clone(), cx);
1193 // Register the native agent right away, so that it is available for
1194 // the inline assistant etc.
1195 store.request_connection(
1196 Agent::NativeAgent,
1197 Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
1198 cx,
1199 );
1200 store
1201 });
1202 let _project_subscription =
1203 cx.subscribe(&project, |this, _project, event, cx| match event {
1204 project::Event::WorktreeAdded(_)
1205 | project::Event::WorktreeRemoved(_)
1206 | project::Event::WorktreeOrderChanged => {
1207 this.update_thread_work_dirs(cx);
1208 }
1209 _ => {}
1210 });
1211 let git_store = project.read(cx).git_store().clone();
1212 let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| {
1213 let should_sync = matches!(
1214 event,
1215 GitStoreEvent::ActiveRepositoryChanged(_)
1216 | GitStoreEvent::RepositoryAdded
1217 | GitStoreEvent::RepositoryRemoved(_)
1218 | GitStoreEvent::RepositoryUpdated(
1219 _,
1220 RepositoryEvent::HeadChanged
1221 | RepositoryEvent::BranchListChanged
1222 | RepositoryEvent::GitWorktreeListChanged,
1223 _,
1224 )
1225 );
1226
1227 if should_sync {
1228 this.sync_start_thread_in_with_git_state(cx);
1229 }
1230 });
1231
1232 let mut panel = Self {
1233 workspace_id,
1234 base_view,
1235 overlay_view: None,
1236 workspace,
1237 user_store,
1238 project: project.clone(),
1239 fs: fs.clone(),
1240 language_registry,
1241 prompt_store,
1242 connection_store,
1243 configuration: None,
1244 configuration_subscription: None,
1245 focus_handle: cx.focus_handle(),
1246 context_server_registry,
1247 retained_threads: HashMap::default(),
1248 new_thread_menu_handle: PopoverMenuHandle::default(),
1249 start_thread_in_menu_handle: PopoverMenuHandle::default(),
1250 thread_branch_menu_handle: PopoverMenuHandle::default(),
1251 agent_panel_menu_handle: PopoverMenuHandle::default(),
1252 agent_navigation_menu_handle: PopoverMenuHandle::default(),
1253 agent_navigation_menu: None,
1254 _extension_subscription: extension_subscription,
1255 _project_subscription,
1256 _git_store_subscription,
1257 zoomed: false,
1258 pending_serialization: None,
1259 new_user_onboarding: onboarding,
1260 agent_layout_onboarding,
1261 thread_store,
1262 selected_agent: Agent::default(),
1263 start_thread_in: StartThreadIn::default(),
1264 pending_thread_loads: 0,
1265 worktree_creation_status: None,
1266 _thread_view_subscription: None,
1267 _active_thread_focus_subscription: None,
1268 _worktree_creation_task: None,
1269 show_trust_workspace_message: false,
1270 new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
1271 agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
1272 cx,
1273 )),
1274 _base_view_observation: None,
1275 };
1276
1277 // Initial sync of agent servers from extensions
1278 panel.sync_agent_servers_from_extensions(cx);
1279 panel
1280 }
1281
1282 pub fn toggle_focus(
1283 workspace: &mut Workspace,
1284 _: &ToggleFocus,
1285 window: &mut Window,
1286 cx: &mut Context<Workspace>,
1287 ) {
1288 if workspace
1289 .panel::<Self>(cx)
1290 .is_some_and(|panel| panel.read(cx).enabled(cx))
1291 {
1292 workspace.toggle_panel_focus::<Self>(window, cx);
1293 }
1294 }
1295
1296 pub fn toggle(
1297 workspace: &mut Workspace,
1298 _: &Toggle,
1299 window: &mut Window,
1300 cx: &mut Context<Workspace>,
1301 ) {
1302 if workspace
1303 .panel::<Self>(cx)
1304 .is_some_and(|panel| panel.read(cx).enabled(cx))
1305 {
1306 if !workspace.toggle_panel_focus::<Self>(window, cx) {
1307 workspace.close_panel::<Self>(window, cx);
1308 }
1309 }
1310 }
1311
1312 pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
1313 &self.prompt_store
1314 }
1315
1316 pub fn thread_store(&self) -> &Entity<ThreadStore> {
1317 &self.thread_store
1318 }
1319
1320 pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
1321 &self.connection_store
1322 }
1323
1324 pub fn open_thread(
1325 &mut self,
1326 session_id: acp::SessionId,
1327 work_dirs: Option<PathList>,
1328 title: Option<SharedString>,
1329 window: &mut Window,
1330 cx: &mut Context<Self>,
1331 ) {
1332 self.external_thread(
1333 Some(crate::Agent::NativeAgent),
1334 Some(session_id),
1335 work_dirs,
1336 title,
1337 None,
1338 true,
1339 window,
1340 cx,
1341 );
1342 }
1343
1344 pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
1345 &self.context_server_registry
1346 }
1347
1348 pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
1349 let workspace_read = workspace.read(cx);
1350
1351 workspace_read
1352 .panel::<AgentPanel>(cx)
1353 .map(|panel| {
1354 let panel_id = Entity::entity_id(&panel);
1355
1356 workspace_read.all_docks().iter().any(|dock| {
1357 dock.read(cx)
1358 .visible_panel()
1359 .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1360 })
1361 })
1362 .unwrap_or(false)
1363 }
1364
1365 /// Clear any active conversation while preserving a real empty draft.
1366 /// Running non-draft threads are retained in the background.
1367 pub fn clear_active_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1368 self.show_or_create_empty_draft(window, cx);
1369 }
1370
1371 fn show_or_create_empty_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1372 if self.active_thread_is_draft(cx) {
1373 self.clear_overlay_state();
1374 self.refresh_base_view_subscriptions(window, cx);
1375 self.serialize(cx);
1376 cx.emit(AgentPanelEvent::ActiveViewChanged);
1377 cx.notify();
1378 return;
1379 }
1380
1381 if let Some(draft_id) = self.draft_thread_ids(cx).into_iter().next() {
1382 self.activate_retained_thread(draft_id, false, window, cx);
1383 cx.notify();
1384 return;
1385 }
1386
1387 let id = self.create_thread(window, cx);
1388 self.activate_retained_thread(id, false, window, cx);
1389 cx.notify();
1390 }
1391
1392 pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1393 let id = self.create_thread(window, cx);
1394 self.activate_retained_thread(id, true, window, cx);
1395 }
1396
1397 pub fn create_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) -> ThreadId {
1398 let agent = if self.project.read(cx).is_via_collab() {
1399 Agent::NativeAgent
1400 } else {
1401 self.selected_agent.clone()
1402 };
1403 let thread = self.create_agent_thread(agent, None, None, None, None, window, cx);
1404 let thread_id = thread.conversation_view.read(cx).thread_id;
1405 self.retained_threads
1406 .insert(thread_id, thread.conversation_view);
1407 thread_id
1408 }
1409
1410 pub fn activate_retained_thread(
1411 &mut self,
1412 id: ThreadId,
1413 focus: bool,
1414 window: &mut Window,
1415 cx: &mut Context<Self>,
1416 ) {
1417 let Some(conversation_view) = self.retained_threads.remove(&id) else {
1418 return;
1419 };
1420 self.set_base_view(
1421 BaseView::AgentThread { conversation_view },
1422 focus,
1423 window,
1424 cx,
1425 );
1426 }
1427
1428 pub fn remove_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
1429 self.retained_threads.remove(&id);
1430 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
1431 store.delete(id, cx);
1432 });
1433
1434 if self.active_thread_id(cx) == Some(id) && self.active_thread_is_draft(cx) {
1435 self.base_view = BaseView::Uninitialized;
1436 self.clear_overlay_state();
1437 self._thread_view_subscription = None;
1438 self._active_thread_focus_subscription = None;
1439 self._base_view_observation = None;
1440 self.serialize(cx);
1441 cx.emit(AgentPanelEvent::ActiveViewChanged);
1442 cx.notify();
1443 }
1444 }
1445
1446 pub fn active_thread_id(&self, cx: &App) -> Option<ThreadId> {
1447 match &self.base_view {
1448 BaseView::AgentThread { conversation_view } => {
1449 Some(conversation_view.read(cx).thread_id)
1450 }
1451 _ => None,
1452 }
1453 }
1454
1455 pub fn draft_thread_ids(&self, cx: &App) -> Vec<ThreadId> {
1456 let is_draft = |cv: &Entity<ConversationView>| -> bool {
1457 let cv = cv.read(cx);
1458 match cv.root_thread(cx) {
1459 Some(tv) => tv.read(cx).is_draft(cx),
1460 None => cv.is_new_draft(),
1461 }
1462 };
1463
1464 let mut ids: Vec<ThreadId> = self
1465 .retained_threads
1466 .iter()
1467 .filter(|(_, cv)| is_draft(cv))
1468 .map(|(id, _)| *id)
1469 .collect();
1470
1471 if let BaseView::AgentThread { conversation_view } = &self.base_view {
1472 let thread_id = conversation_view.read(cx).thread_id;
1473 if is_draft(conversation_view) && !ids.contains(&thread_id) {
1474 ids.push(thread_id);
1475 }
1476 }
1477
1478 if let Some(store) = ThreadMetadataStore::try_global(cx) {
1479 let store = store.read(cx);
1480 ids.sort_by(|a, b| {
1481 let a_time = store.entry(*a).and_then(|m| m.created_at);
1482 let b_time = store.entry(*b).and_then(|m| m.created_at);
1483 b_time.cmp(&a_time)
1484 });
1485 }
1486 ids
1487 }
1488
1489 pub fn editor_text(&self, id: ThreadId, cx: &App) -> Option<String> {
1490 let cv = self
1491 .retained_threads
1492 .get(&id)
1493 .or_else(|| match &self.base_view {
1494 BaseView::AgentThread { conversation_view }
1495 if conversation_view.read(cx).thread_id == id =>
1496 {
1497 Some(conversation_view)
1498 }
1499 _ => None,
1500 })?;
1501 let tv = cv.read(cx).active_thread()?;
1502 let text = tv.read(cx).message_editor.read(cx).text(cx);
1503 if text.trim().is_empty() {
1504 None
1505 } else {
1506 Some(text)
1507 }
1508 }
1509
1510 pub fn clear_editor(&self, id: ThreadId, window: &mut Window, cx: &mut Context<Self>) {
1511 let cv = self
1512 .retained_threads
1513 .get(&id)
1514 .or_else(|| match &self.base_view {
1515 BaseView::AgentThread { conversation_view }
1516 if conversation_view.read(cx).thread_id == id =>
1517 {
1518 Some(conversation_view)
1519 }
1520 _ => None,
1521 });
1522 let Some(cv) = cv else { return };
1523 let Some(tv) = cv.read(cx).active_thread() else {
1524 return;
1525 };
1526 let editor = tv.read(cx).message_editor.clone();
1527 editor.update(cx, |editor, cx| {
1528 editor.clear(window, cx);
1529 });
1530 }
1531
1532 fn take_active_initial_content(
1533 &mut self,
1534 cx: &mut Context<Self>,
1535 ) -> Option<AgentInitialContent> {
1536 self.active_thread_view(cx).and_then(|thread_view| {
1537 thread_view.update(cx, |thread_view, cx| {
1538 let draft_blocks = thread_view
1539 .thread
1540 .read(cx)
1541 .draft_prompt()
1542 .map(|draft| draft.to_vec())
1543 .filter(|draft| !draft.is_empty());
1544
1545 let draft_blocks = draft_blocks.or_else(|| {
1546 let text = thread_view.message_editor.read(cx).text(cx);
1547 if text.trim().is_empty() {
1548 None
1549 } else {
1550 Some(vec![acp::ContentBlock::Text(acp::TextContent::new(text))])
1551 }
1552 });
1553
1554 draft_blocks.map(|blocks| AgentInitialContent::ContentBlock {
1555 blocks,
1556 auto_submit: false,
1557 })
1558 })
1559 })
1560 }
1561
1562 fn new_native_agent_thread_from_summary(
1563 &mut self,
1564 action: &NewNativeAgentThreadFromSummary,
1565 window: &mut Window,
1566 cx: &mut Context<Self>,
1567 ) {
1568 let session_id = action.from_session_id.clone();
1569
1570 let Some(history) = self
1571 .connection_store
1572 .read(cx)
1573 .entry(&Agent::NativeAgent)
1574 .and_then(|e| e.read(cx).history().cloned())
1575 else {
1576 debug_panic!("Native agent is not registered");
1577 return;
1578 };
1579
1580 cx.spawn_in(window, async move |this, cx| {
1581 this.update_in(cx, |this, window, cx| {
1582 let thread = history
1583 .read(cx)
1584 .session_for_id(&session_id)
1585 .context("Session not found")?;
1586
1587 this.external_thread(
1588 Some(Agent::NativeAgent),
1589 None,
1590 None,
1591 None,
1592 Some(AgentInitialContent::ThreadSummary {
1593 session_id: thread.session_id,
1594 title: thread.title,
1595 }),
1596 true,
1597 window,
1598 cx,
1599 );
1600 anyhow::Ok(())
1601 })
1602 })
1603 .detach_and_log_err(cx);
1604 }
1605
1606 fn external_thread(
1607 &mut self,
1608 agent_choice: Option<crate::Agent>,
1609 resume_session_id: Option<acp::SessionId>,
1610 work_dirs: Option<PathList>,
1611 title: Option<SharedString>,
1612 initial_content: Option<AgentInitialContent>,
1613 focus: bool,
1614 window: &mut Window,
1615 cx: &mut Context<Self>,
1616 ) {
1617 let agent = agent_choice.unwrap_or_else(|| {
1618 if self.project.read(cx).is_via_collab() {
1619 Agent::NativeAgent
1620 } else {
1621 self.selected_agent.clone()
1622 }
1623 });
1624 let thread = self.create_agent_thread(
1625 agent,
1626 resume_session_id,
1627 work_dirs,
1628 title,
1629 initial_content,
1630 window,
1631 cx,
1632 );
1633 self.set_base_view(thread.into(), focus, window, cx);
1634 }
1635
1636 fn deploy_rules_library(
1637 &mut self,
1638 action: &OpenRulesLibrary,
1639 _window: &mut Window,
1640 cx: &mut Context<Self>,
1641 ) {
1642 open_rules_library(
1643 self.language_registry.clone(),
1644 Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1645 action
1646 .prompt_to_select
1647 .map(|uuid| UserPromptId(uuid).into()),
1648 cx,
1649 )
1650 .detach_and_log_err(cx);
1651 }
1652
1653 fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1654 let Some(conversation_view) = self.active_conversation_view() else {
1655 return;
1656 };
1657
1658 let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else {
1659 return;
1660 };
1661
1662 active_thread.update(cx, |active_thread, cx| {
1663 active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1664 active_thread.focus_handle(cx).focus(window, cx);
1665 })
1666 }
1667
1668 fn has_history_for_selected_agent(&self, cx: &App) -> bool {
1669 match &self.selected_agent {
1670 Agent::NativeAgent => true,
1671 Agent::Custom { .. } => self
1672 .connection_store
1673 .read(cx)
1674 .entry(&self.selected_agent)
1675 .map_or(false, |entry| entry.read(cx).history().is_some()),
1676 }
1677 }
1678
1679 fn history_for_selected_agent(
1680 &self,
1681 window: &mut Window,
1682 cx: &mut Context<Self>,
1683 ) -> Option<Entity<ThreadHistoryView>> {
1684 let agent = self.selected_agent.clone();
1685 let history = self
1686 .connection_store
1687 .read(cx)
1688 .entry(&agent)?
1689 .read(cx)
1690 .history()?
1691 .clone();
1692 Some(self.create_thread_history_view(agent, history, window, cx))
1693 }
1694
1695 fn create_thread_history_view(
1696 &self,
1697 agent: Agent,
1698 history: Entity<ThreadHistory>,
1699 window: &mut Window,
1700 cx: &mut Context<Self>,
1701 ) -> Entity<ThreadHistoryView> {
1702 let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
1703 cx.subscribe_in(
1704 &view,
1705 window,
1706 move |this, _, event, window, cx| match event {
1707 ThreadHistoryViewEvent::Open(thread) => {
1708 this.load_agent_thread(
1709 agent.clone(),
1710 thread.session_id.clone(),
1711 thread.work_dirs.clone(),
1712 thread.title.clone(),
1713 true,
1714 window,
1715 cx,
1716 );
1717 }
1718 },
1719 )
1720 .detach();
1721 view
1722 }
1723
1724 fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1725 if matches!(self.overlay_view, Some(OverlayView::History { .. })) {
1726 self.clear_overlay(true, window, cx);
1727 return;
1728 }
1729
1730 let Some(view) = self.history_for_selected_agent(window, cx) else {
1731 return;
1732 };
1733
1734 self.set_overlay(OverlayView::History { view }, true, window, cx);
1735 cx.notify();
1736 }
1737
1738 pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1739 if self.overlay_view.is_some() {
1740 self.clear_overlay(true, window, cx);
1741 cx.notify();
1742 }
1743 }
1744
1745 pub fn toggle_navigation_menu(
1746 &mut self,
1747 _: &ToggleNavigationMenu,
1748 window: &mut Window,
1749 cx: &mut Context<Self>,
1750 ) {
1751 if !self.has_history_for_selected_agent(cx) {
1752 return;
1753 }
1754 self.agent_navigation_menu_handle.toggle(window, cx);
1755 }
1756
1757 pub fn toggle_options_menu(
1758 &mut self,
1759 _: &ToggleOptionsMenu,
1760 window: &mut Window,
1761 cx: &mut Context<Self>,
1762 ) {
1763 self.agent_panel_menu_handle.toggle(window, cx);
1764 }
1765
1766 pub fn toggle_new_thread_menu(
1767 &mut self,
1768 _: &ToggleNewThreadMenu,
1769 window: &mut Window,
1770 cx: &mut Context<Self>,
1771 ) {
1772 self.new_thread_menu_handle.toggle(window, cx);
1773 }
1774
1775 pub fn increase_font_size(
1776 &mut self,
1777 action: &IncreaseBufferFontSize,
1778 _: &mut Window,
1779 cx: &mut Context<Self>,
1780 ) {
1781 self.handle_font_size_action(action.persist, px(1.0), cx);
1782 }
1783
1784 pub fn decrease_font_size(
1785 &mut self,
1786 action: &DecreaseBufferFontSize,
1787 _: &mut Window,
1788 cx: &mut Context<Self>,
1789 ) {
1790 self.handle_font_size_action(action.persist, px(-1.0), cx);
1791 }
1792
1793 fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1794 match self.visible_font_size() {
1795 WhichFontSize::AgentFont => {
1796 if persist {
1797 update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1798 let agent_ui_font_size =
1799 ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1800 let agent_buffer_font_size =
1801 ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1802
1803 let _ = settings.theme.agent_ui_font_size.insert(
1804 f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(),
1805 );
1806 let _ = settings.theme.agent_buffer_font_size.insert(
1807 f32::from(theme_settings::clamp_font_size(agent_buffer_font_size))
1808 .into(),
1809 );
1810 });
1811 } else {
1812 theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta);
1813 theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta);
1814 }
1815 }
1816 WhichFontSize::None => {}
1817 }
1818 }
1819
1820 pub fn reset_font_size(
1821 &mut self,
1822 action: &ResetBufferFontSize,
1823 _: &mut Window,
1824 cx: &mut Context<Self>,
1825 ) {
1826 if action.persist {
1827 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1828 settings.theme.agent_ui_font_size = None;
1829 settings.theme.agent_buffer_font_size = None;
1830 });
1831 } else {
1832 theme_settings::reset_agent_ui_font_size(cx);
1833 theme_settings::reset_agent_buffer_font_size(cx);
1834 }
1835 }
1836
1837 pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1838 theme_settings::reset_agent_ui_font_size(cx);
1839 theme_settings::reset_agent_buffer_font_size(cx);
1840 }
1841
1842 pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1843 if self.zoomed {
1844 cx.emit(PanelEvent::ZoomOut);
1845 } else {
1846 if !self.focus_handle(cx).contains_focused(window, cx) {
1847 cx.focus_self(window);
1848 }
1849 cx.emit(PanelEvent::ZoomIn);
1850 }
1851 }
1852
1853 pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1854 if matches!(self.overlay_view, Some(OverlayView::Configuration)) {
1855 self.clear_overlay(true, window, cx);
1856 return;
1857 }
1858
1859 let agent_server_store = self.project.read(cx).agent_server_store().clone();
1860 let context_server_store = self.project.read(cx).context_server_store();
1861 let fs = self.fs.clone();
1862
1863 self.configuration = Some(cx.new(|cx| {
1864 AgentConfiguration::new(
1865 fs,
1866 agent_server_store,
1867 self.connection_store.clone(),
1868 context_server_store,
1869 self.context_server_registry.clone(),
1870 self.language_registry.clone(),
1871 self.workspace.clone(),
1872 window,
1873 cx,
1874 )
1875 }));
1876
1877 if let Some(configuration) = self.configuration.as_ref() {
1878 self.configuration_subscription = Some(cx.subscribe_in(
1879 configuration,
1880 window,
1881 Self::handle_agent_configuration_event,
1882 ));
1883 }
1884
1885 self.set_overlay(OverlayView::Configuration, true, window, cx);
1886
1887 if let Some(configuration) = self.configuration.as_ref() {
1888 configuration.focus_handle(cx).focus(window, cx);
1889 }
1890 }
1891
1892 pub(crate) fn open_active_thread_as_markdown(
1893 &mut self,
1894 _: &OpenActiveThreadAsMarkdown,
1895 window: &mut Window,
1896 cx: &mut Context<Self>,
1897 ) {
1898 if let Some(workspace) = self.workspace.upgrade()
1899 && let Some(conversation_view) = self.active_conversation_view()
1900 && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned()
1901 {
1902 active_thread.update(cx, |thread, cx| {
1903 thread
1904 .open_thread_as_markdown(workspace, window, cx)
1905 .detach_and_log_err(cx);
1906 });
1907 }
1908 }
1909
1910 fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1911 let Some(thread) = self.active_native_agent_thread(cx) else {
1912 Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1913 return;
1914 };
1915
1916 let workspace = self.workspace.clone();
1917 let load_task = thread.read(cx).to_db(cx);
1918
1919 cx.spawn_in(window, async move |_this, cx| {
1920 let db_thread = load_task.await;
1921 let shared_thread = SharedThread::from_db_thread(&db_thread);
1922 let thread_data = shared_thread.to_bytes()?;
1923 let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1924
1925 cx.update(|_window, cx| {
1926 cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1927 if let Some(workspace) = workspace.upgrade() {
1928 workspace.update(cx, |workspace, cx| {
1929 struct ThreadCopiedToast;
1930 workspace.show_toast(
1931 workspace::Toast::new(
1932 workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1933 "Thread copied to clipboard (base64 encoded)",
1934 )
1935 .autohide(),
1936 cx,
1937 );
1938 });
1939 }
1940 })?;
1941
1942 anyhow::Ok(())
1943 })
1944 .detach_and_log_err(cx);
1945 }
1946
1947 fn show_deferred_toast(
1948 workspace: &WeakEntity<workspace::Workspace>,
1949 message: &'static str,
1950 cx: &mut App,
1951 ) {
1952 let workspace = workspace.clone();
1953 cx.defer(move |cx| {
1954 if let Some(workspace) = workspace.upgrade() {
1955 workspace.update(cx, |workspace, cx| {
1956 struct ClipboardToast;
1957 workspace.show_toast(
1958 workspace::Toast::new(
1959 workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1960 message,
1961 )
1962 .autohide(),
1963 cx,
1964 );
1965 });
1966 }
1967 });
1968 }
1969
1970 fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1971 let Some(clipboard) = cx.read_from_clipboard() else {
1972 Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1973 return;
1974 };
1975
1976 let Some(encoded) = clipboard.text() else {
1977 Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1978 return;
1979 };
1980
1981 let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1982 {
1983 Ok(data) => data,
1984 Err(_) => {
1985 Self::show_deferred_toast(
1986 &self.workspace,
1987 "Failed to decode clipboard content (expected base64)",
1988 cx,
1989 );
1990 return;
1991 }
1992 };
1993
1994 let shared_thread = match SharedThread::from_bytes(&thread_data) {
1995 Ok(thread) => thread,
1996 Err(_) => {
1997 Self::show_deferred_toast(
1998 &self.workspace,
1999 "Failed to parse thread data from clipboard",
2000 cx,
2001 );
2002 return;
2003 }
2004 };
2005
2006 let db_thread = shared_thread.to_db_thread();
2007 let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
2008 let thread_store = self.thread_store.clone();
2009 let title = db_thread.title.clone();
2010 let workspace = self.workspace.clone();
2011
2012 cx.spawn_in(window, async move |this, cx| {
2013 thread_store
2014 .update(&mut cx.clone(), |store, cx| {
2015 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
2016 })
2017 .await?;
2018
2019 this.update_in(cx, |this, window, cx| {
2020 this.open_thread(session_id, None, Some(title), window, cx);
2021 })?;
2022
2023 this.update_in(cx, |_, _window, cx| {
2024 if let Some(workspace) = workspace.upgrade() {
2025 workspace.update(cx, |workspace, cx| {
2026 struct ThreadLoadedToast;
2027 workspace.show_toast(
2028 workspace::Toast::new(
2029 workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
2030 "Thread loaded from clipboard",
2031 )
2032 .autohide(),
2033 cx,
2034 );
2035 });
2036 }
2037 })?;
2038
2039 anyhow::Ok(())
2040 })
2041 .detach_and_log_err(cx);
2042 }
2043
2044 fn handle_agent_configuration_event(
2045 &mut self,
2046 _entity: &Entity<AgentConfiguration>,
2047 event: &AssistantConfigurationEvent,
2048 window: &mut Window,
2049 cx: &mut Context<Self>,
2050 ) {
2051 match event {
2052 AssistantConfigurationEvent::NewThread(provider) => {
2053 if LanguageModelRegistry::read_global(cx)
2054 .default_model()
2055 .is_none_or(|model| model.provider.id() != provider.id())
2056 && let Some(model) = provider.default_model(cx)
2057 {
2058 update_settings_file(self.fs.clone(), cx, move |settings, _| {
2059 let provider = model.provider_id().0.to_string();
2060 let enable_thinking = model.supports_thinking();
2061 let effort = model
2062 .default_effort_level()
2063 .map(|effort| effort.value.to_string());
2064 let model = model.id().0.to_string();
2065 settings
2066 .agent
2067 .get_or_insert_default()
2068 .set_model(LanguageModelSelection {
2069 provider: LanguageModelProviderSetting(provider),
2070 model,
2071 enable_thinking,
2072 effort,
2073 speed: None,
2074 })
2075 });
2076 }
2077
2078 self.new_thread(&NewThread, window, cx);
2079 if let Some((thread, model)) = self
2080 .active_native_agent_thread(cx)
2081 .zip(provider.default_model(cx))
2082 {
2083 thread.update(cx, |thread, cx| {
2084 thread.set_model(model, cx);
2085 });
2086 }
2087 }
2088 }
2089 }
2090
2091 pub fn workspace_id(&self) -> Option<WorkspaceId> {
2092 self.workspace_id
2093 }
2094
2095 pub fn retained_threads(&self) -> &HashMap<ThreadId, Entity<ConversationView>> {
2096 &self.retained_threads
2097 }
2098
2099 pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
2100 match &self.base_view {
2101 BaseView::AgentThread { conversation_view } => Some(conversation_view),
2102 _ => None,
2103 }
2104 }
2105
2106 pub fn conversation_views(&self) -> Vec<Entity<ConversationView>> {
2107 self.active_conversation_view()
2108 .into_iter()
2109 .cloned()
2110 .chain(self.retained_threads.values().cloned())
2111 .collect()
2112 }
2113
2114 pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
2115 let server_view = self.active_conversation_view()?;
2116 server_view.read(cx).active_thread().cloned()
2117 }
2118
2119 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
2120 match &self.base_view {
2121 BaseView::AgentThread { conversation_view } => conversation_view
2122 .read(cx)
2123 .active_thread()
2124 .map(|r| r.read(cx).thread.clone()),
2125 _ => None,
2126 }
2127 }
2128
2129 pub fn is_retained_thread(&self, id: &ThreadId) -> bool {
2130 self.retained_threads.contains_key(id)
2131 }
2132
2133 pub fn cancel_thread(&self, thread_id: &ThreadId, cx: &mut Context<Self>) -> bool {
2134 let conversation_views = self
2135 .active_conversation_view()
2136 .into_iter()
2137 .chain(self.retained_threads.values());
2138
2139 for conversation_view in conversation_views {
2140 if *thread_id == conversation_view.read(cx).thread_id {
2141 if let Some(thread_view) = conversation_view.read(cx).root_thread_view(cx) {
2142 thread_view.update(cx, |view, cx| view.cancel_generation(cx));
2143 return true;
2144 }
2145 }
2146 }
2147 false
2148 }
2149
2150 /// active thread plus any background threads that are still running or
2151 /// completed but unseen.
2152 pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
2153 let mut views = Vec::new();
2154
2155 if let Some(server_view) = self.active_conversation_view() {
2156 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
2157 views.push(thread_view);
2158 }
2159 }
2160
2161 for server_view in self.retained_threads.values() {
2162 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
2163 views.push(thread_view);
2164 }
2165 }
2166
2167 views
2168 }
2169
2170 fn update_thread_work_dirs(&self, cx: &mut Context<Self>) {
2171 let new_work_dirs = self.project.read(cx).default_path_list(cx);
2172 let new_worktree_paths = self.project.read(cx).worktree_paths(cx);
2173
2174 if let Some(conversation_view) = self.active_conversation_view() {
2175 conversation_view.update(cx, |conversation_view, cx| {
2176 conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
2177 });
2178 }
2179
2180 for conversation_view in self.retained_threads.values() {
2181 conversation_view.update(cx, |conversation_view, cx| {
2182 conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
2183 });
2184 }
2185
2186 // Update metadata store so threads' path lists stay in sync with
2187 // the project's current worktrees. Without this, threads saved
2188 // before a worktree was added would have stale paths and not
2189 // appear under the correct sidebar group.
2190 let mut thread_ids: Vec<ThreadId> = self.retained_threads.keys().copied().collect();
2191 if let Some(active_id) = self.active_thread_id(cx) {
2192 thread_ids.push(active_id);
2193 }
2194 if !thread_ids.is_empty() {
2195 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
2196 store.update_worktree_paths(&thread_ids, new_worktree_paths, cx);
2197 });
2198 }
2199 }
2200
2201 fn retain_running_thread(&mut self, old_view: BaseView, cx: &mut Context<Self>) {
2202 let BaseView::AgentThread { conversation_view } = old_view else {
2203 return;
2204 };
2205
2206 let thread_id = conversation_view.read(cx).thread_id;
2207
2208 if self.retained_threads.contains_key(&thread_id) {
2209 return;
2210 }
2211
2212 let is_empty_draft = match conversation_view.read(cx).root_thread(cx) {
2213 Some(tv) => tv.read(cx).is_draft(cx),
2214 None => conversation_view.read(cx).is_new_draft(),
2215 };
2216 if is_empty_draft {
2217 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
2218 store.delete(thread_id, cx);
2219 });
2220 return;
2221 }
2222
2223 self.retained_threads.insert(thread_id, conversation_view);
2224 self.cleanup_retained_threads(cx);
2225 }
2226
2227 fn remove_empty_draft(&mut self, cx: &mut Context<Self>) {
2228 let draft_ids: Vec<ThreadId> = self
2229 .retained_threads
2230 .iter()
2231 .filter(|(_, cv)| match cv.read(cx).root_thread(cx) {
2232 Some(tv) => tv.read(cx).is_draft(cx),
2233 None => cv.read(cx).is_new_draft(),
2234 })
2235 .map(|(id, _)| *id)
2236 .collect();
2237 for id in draft_ids {
2238 self.retained_threads.remove(&id);
2239 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
2240 store.delete(id, cx);
2241 });
2242 }
2243
2244 // Also clean up orphaned draft metadata in the store for this
2245 // panel's worktree paths (e.g. from a previously removed workspace).
2246 let path_list = {
2247 let project = self.project.read(cx);
2248 let worktree_paths = project.worktree_paths(cx);
2249 worktree_paths.main_worktree_path_list().clone()
2250 };
2251 if let Some(store) = ThreadMetadataStore::try_global(cx) {
2252 let orphaned: Vec<ThreadId> = {
2253 let store = store.read(cx);
2254 store
2255 .entries_for_path(&path_list)
2256 .chain(store.entries_for_main_worktree_path(&path_list))
2257 .filter(|entry| entry.is_draft())
2258 .map(|entry| entry.thread_id)
2259 .collect()
2260 };
2261 if !orphaned.is_empty() {
2262 store.update(cx, |store, cx| {
2263 for id in orphaned {
2264 store.delete(id, cx);
2265 }
2266 });
2267 }
2268 }
2269 }
2270
2271 fn cleanup_retained_threads(&mut self, cx: &App) {
2272 let mut potential_removals = self
2273 .retained_threads
2274 .iter()
2275 .filter(|(_id, view)| {
2276 let Some(thread_view) = view.read(cx).root_thread(cx) else {
2277 return true;
2278 };
2279 let thread = thread_view.read(cx).thread.read(cx);
2280 thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
2281 })
2282 .collect::<Vec<_>>();
2283
2284 let max_idle = MaxIdleRetainedThreads::global(cx);
2285
2286 potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
2287 let n = potential_removals.len().saturating_sub(max_idle);
2288 let to_remove = potential_removals
2289 .into_iter()
2290 .map(|(id, _)| *id)
2291 .take(n)
2292 .collect::<Vec<_>>();
2293 for id in to_remove {
2294 self.retained_threads.remove(&id);
2295 }
2296 }
2297
2298 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2299 match &self.base_view {
2300 BaseView::AgentThread { conversation_view } => {
2301 conversation_view.read(cx).as_native_thread(cx)
2302 }
2303 _ => None,
2304 }
2305 }
2306
2307 fn set_base_view(
2308 &mut self,
2309 new_view: BaseView,
2310 focus: bool,
2311 window: &mut Window,
2312 cx: &mut Context<Self>,
2313 ) {
2314 self.clear_overlay_state();
2315
2316 let old_view = std::mem::replace(&mut self.base_view, new_view);
2317 self.retain_running_thread(old_view, cx);
2318
2319 if let BaseView::AgentThread { conversation_view } = &self.base_view {
2320 let thread_agent = conversation_view.read(cx).agent_key().clone();
2321 if self.selected_agent != thread_agent {
2322 self.selected_agent = thread_agent;
2323 self.serialize(cx);
2324 }
2325 }
2326
2327 self.refresh_base_view_subscriptions(window, cx);
2328
2329 if focus {
2330 self.focus_handle(cx).focus(window, cx);
2331 }
2332 cx.emit(AgentPanelEvent::ActiveViewChanged);
2333 }
2334
2335 fn set_overlay(
2336 &mut self,
2337 overlay: OverlayView,
2338 focus: bool,
2339 window: &mut Window,
2340 cx: &mut Context<Self>,
2341 ) {
2342 let was_in_history = matches!(self.overlay_view, Some(OverlayView::History { .. }));
2343 self.overlay_view = Some(overlay);
2344
2345 if let Some(OverlayView::History { view }) = &self.overlay_view
2346 && !was_in_history
2347 {
2348 view.update(cx, |view, cx| {
2349 view.history()
2350 .update(cx, |history, cx| history.refresh_full_history(cx))
2351 });
2352 }
2353
2354 if focus {
2355 self.focus_handle(cx).focus(window, cx);
2356 }
2357 cx.emit(AgentPanelEvent::ActiveViewChanged);
2358 }
2359
2360 fn clear_overlay(&mut self, focus: bool, window: &mut Window, cx: &mut Context<Self>) {
2361 self.clear_overlay_state();
2362
2363 if focus {
2364 self.focus_handle(cx).focus(window, cx);
2365 }
2366 cx.emit(AgentPanelEvent::ActiveViewChanged);
2367 }
2368
2369 fn clear_overlay_state(&mut self) {
2370 self.overlay_view = None;
2371 self.configuration_subscription = None;
2372 self.configuration = None;
2373 }
2374
2375 fn refresh_base_view_subscriptions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2376 self._base_view_observation = match &self.base_view {
2377 BaseView::AgentThread { conversation_view } => {
2378 self._thread_view_subscription =
2379 Self::subscribe_to_active_thread_view(conversation_view, window, cx);
2380 let focus_handle = conversation_view.focus_handle(cx);
2381 self._active_thread_focus_subscription =
2382 Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
2383 cx.emit(AgentPanelEvent::ThreadFocused);
2384 cx.notify();
2385 }));
2386 Some(cx.observe_in(
2387 conversation_view,
2388 window,
2389 |this, server_view, window, cx| {
2390 this._thread_view_subscription =
2391 Self::subscribe_to_active_thread_view(&server_view, window, cx);
2392 cx.emit(AgentPanelEvent::ActiveViewChanged);
2393 this.serialize(cx);
2394 cx.notify();
2395 },
2396 ))
2397 }
2398 BaseView::Uninitialized => {
2399 self._thread_view_subscription = None;
2400 self._active_thread_focus_subscription = None;
2401 None
2402 }
2403 };
2404 self.serialize(cx);
2405 }
2406
2407 fn visible_surface(&self) -> VisibleSurface<'_> {
2408 if let Some(overlay_view) = &self.overlay_view {
2409 return match overlay_view {
2410 OverlayView::History { view } => VisibleSurface::History(view),
2411 OverlayView::Configuration => {
2412 VisibleSurface::Configuration(self.configuration.as_ref())
2413 }
2414 };
2415 }
2416
2417 match &self.base_view {
2418 BaseView::Uninitialized => VisibleSurface::Uninitialized,
2419 BaseView::AgentThread { conversation_view } => {
2420 VisibleSurface::AgentThread(conversation_view)
2421 }
2422 }
2423 }
2424
2425 fn is_overlay_open(&self) -> bool {
2426 self.overlay_view.is_some()
2427 }
2428
2429 fn is_history_or_configuration_visible(&self) -> bool {
2430 self.is_overlay_open()
2431 }
2432
2433 fn visible_font_size(&self) -> WhichFontSize {
2434 self.overlay_view.as_ref().map_or_else(
2435 || self.base_view.which_font_size_used(),
2436 OverlayView::which_font_size_used,
2437 )
2438 }
2439
2440 fn populate_recently_updated_menu_section(
2441 mut menu: ContextMenu,
2442 panel: Entity<Self>,
2443 view: Entity<ThreadHistoryView>,
2444 cx: &mut Context<ContextMenu>,
2445 ) -> ContextMenu {
2446 let entries = view
2447 .read(cx)
2448 .history()
2449 .read(cx)
2450 .sessions()
2451 .iter()
2452 .take(RECENTLY_UPDATED_MENU_LIMIT)
2453 .cloned()
2454 .collect::<Vec<_>>();
2455
2456 if entries.is_empty() {
2457 return menu;
2458 }
2459
2460 menu = menu.header("Recently Updated");
2461
2462 for entry in entries {
2463 let title = entry
2464 .title
2465 .as_ref()
2466 .filter(|title| !title.is_empty())
2467 .cloned()
2468 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
2469
2470 menu = menu.entry(title, None, {
2471 let panel = panel.downgrade();
2472 let entry = entry.clone();
2473 move |window, cx| {
2474 let entry = entry.clone();
2475 panel
2476 .update(cx, move |this, cx| {
2477 if let Some(agent) = this.selected_agent() {
2478 this.load_agent_thread(
2479 agent,
2480 entry.session_id.clone(),
2481 entry.work_dirs.clone(),
2482 entry.title.clone(),
2483 true,
2484 window,
2485 cx,
2486 );
2487 }
2488 })
2489 .ok();
2490 }
2491 });
2492 }
2493
2494 menu.separator()
2495 }
2496
2497 fn subscribe_to_active_thread_view(
2498 server_view: &Entity<ConversationView>,
2499 window: &mut Window,
2500 cx: &mut Context<Self>,
2501 ) -> Option<Subscription> {
2502 server_view.read(cx).active_thread().cloned().map(|tv| {
2503 cx.subscribe_in(
2504 &tv,
2505 window,
2506 |this, view, event: &AcpThreadViewEvent, window, cx| match event {
2507 AcpThreadViewEvent::FirstSendRequested { content } => {
2508 this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
2509 }
2510 AcpThreadViewEvent::MessageSentOrQueued => {
2511 let Some(thread_id) = this.active_thread_id(cx) else {
2512 return;
2513 };
2514 this.retained_threads.remove(&thread_id);
2515 cx.emit(AgentPanelEvent::MessageSentOrQueued { thread_id });
2516 }
2517 },
2518 )
2519 })
2520 }
2521
2522 pub fn start_thread_in(&self) -> &StartThreadIn {
2523 &self.start_thread_in
2524 }
2525
2526 fn set_start_thread_in(
2527 &mut self,
2528 action: &StartThreadIn,
2529 window: &mut Window,
2530 cx: &mut Context<Self>,
2531 ) {
2532 let new_target = match action {
2533 StartThreadIn::LocalProject => StartThreadIn::LocalProject,
2534 StartThreadIn::NewWorktree { .. } => {
2535 if !self.project_has_git_repository(cx) {
2536 log::error!(
2537 "set_start_thread_in: cannot use worktree mode without a git repository"
2538 );
2539 return;
2540 }
2541 if self.project.read(cx).is_via_collab() {
2542 log::error!(
2543 "set_start_thread_in: cannot use worktree mode in a collab project"
2544 );
2545 return;
2546 }
2547 action.clone()
2548 }
2549 StartThreadIn::LinkedWorktree { .. } => {
2550 if !self.project_has_git_repository(cx) {
2551 log::error!(
2552 "set_start_thread_in: cannot use LinkedWorktree without a git repository"
2553 );
2554 return;
2555 }
2556 if self.project.read(cx).is_via_collab() {
2557 log::error!(
2558 "set_start_thread_in: cannot use LinkedWorktree in a collab project"
2559 );
2560 return;
2561 }
2562 action.clone()
2563 }
2564 };
2565 self.start_thread_in = new_target;
2566 if let Some(thread) = self.active_thread_view(cx) {
2567 thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
2568 }
2569 self.serialize(cx);
2570 cx.notify();
2571 }
2572
2573 fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2574 let next = match &self.start_thread_in {
2575 StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
2576 worktree_name: None,
2577 branch_target: NewWorktreeBranchTarget::default(),
2578 },
2579 StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
2580 StartThreadIn::LocalProject
2581 }
2582 };
2583 self.set_start_thread_in(&next, window, cx);
2584 }
2585
2586 fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
2587 use settings::{NewThreadLocation, Settings};
2588 let default = AgentSettings::get_global(cx).new_thread_location;
2589 let start_thread_in = match default {
2590 NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
2591 NewThreadLocation::NewWorktree => {
2592 if self.project_has_git_repository(cx) {
2593 StartThreadIn::NewWorktree {
2594 worktree_name: None,
2595 branch_target: NewWorktreeBranchTarget::default(),
2596 }
2597 } else {
2598 StartThreadIn::LocalProject
2599 }
2600 }
2601 };
2602 if self.start_thread_in != start_thread_in {
2603 self.start_thread_in = start_thread_in;
2604 self.serialize(cx);
2605 cx.notify();
2606 }
2607 }
2608
2609 fn sync_start_thread_in_with_git_state(&mut self, cx: &mut Context<Self>) {
2610 if matches!(self.start_thread_in, StartThreadIn::LocalProject) {
2611 return;
2612 }
2613
2614 let visible_worktree_paths: Vec<_> = self
2615 .project
2616 .read(cx)
2617 .visible_worktrees(cx)
2618 .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
2619 .collect();
2620 let repositories = self.project.read(cx).repositories(cx);
2621 let linked_worktrees = if repositories.len() > 1 {
2622 Vec::new()
2623 } else {
2624 repositories
2625 .values()
2626 .flat_map(|repo| repo.read(cx).linked_worktrees().iter().cloned())
2627 .filter(|worktree| !visible_worktree_paths.contains(&worktree.path))
2628 .collect::<Vec<_>>()
2629 };
2630
2631 let updated_start_thread_in = match &self.start_thread_in {
2632 StartThreadIn::NewWorktree {
2633 worktree_name: Some(worktree_name),
2634 branch_target,
2635 } => {
2636 let normalized_worktree_name = worktree_name.replace(' ', "-");
2637 linked_worktrees
2638 .iter()
2639 .find(|worktree| {
2640 worktree.display_name() == normalized_worktree_name
2641 && self.linked_worktree_matches_branch_target(
2642 worktree,
2643 branch_target,
2644 cx,
2645 )
2646 })
2647 .map(|worktree| StartThreadIn::LinkedWorktree {
2648 path: worktree.path.clone(),
2649 display_name: worktree.display_name().to_string(),
2650 })
2651 }
2652 StartThreadIn::LinkedWorktree { path, .. } => linked_worktrees
2653 .iter()
2654 .find(|worktree| worktree.path == *path)
2655 .map(|worktree| StartThreadIn::LinkedWorktree {
2656 path: worktree.path.clone(),
2657 display_name: worktree.display_name().to_string(),
2658 })
2659 .or(Some(StartThreadIn::LocalProject)),
2660 _ => None,
2661 };
2662
2663 if let Some(updated_start_thread_in) = updated_start_thread_in {
2664 if self.start_thread_in != updated_start_thread_in {
2665 self.start_thread_in = updated_start_thread_in;
2666 self.serialize(cx);
2667 }
2668 cx.notify();
2669 }
2670 }
2671
2672 fn linked_worktree_matches_branch_target(
2673 &self,
2674 worktree: &git::repository::Worktree,
2675 branch_target: &NewWorktreeBranchTarget,
2676 cx: &App,
2677 ) -> bool {
2678 let active_repository = self.project.read(cx).active_repository(cx);
2679 let current_branch_name = active_repository.as_ref().and_then(|repo| {
2680 repo.read(cx)
2681 .branch
2682 .as_ref()
2683 .map(|branch| branch.name().to_string())
2684 });
2685 let existing_branch_names = active_repository
2686 .as_ref()
2687 .map(|repo| {
2688 repo.read(cx)
2689 .branch_list
2690 .iter()
2691 .map(|branch| branch.name().to_string())
2692 .collect::<HashSet<_>>()
2693 })
2694 .unwrap_or_default();
2695
2696 match branch_target {
2697 NewWorktreeBranchTarget::CurrentBranch => {
2698 current_branch_name.as_deref() == worktree.branch_name()
2699 }
2700 NewWorktreeBranchTarget::ExistingBranch { name } => {
2701 existing_branch_names.contains(name)
2702 && worktree.branch_name() == Some(name.as_str())
2703 }
2704 NewWorktreeBranchTarget::CreateBranch { name, .. } => {
2705 !existing_branch_names.contains(name)
2706 && worktree.branch_name() == Some(name.as_str())
2707 }
2708 }
2709 }
2710
2711 pub(crate) fn selected_agent(&self) -> Option<Agent> {
2712 Some(self.selected_agent.clone())
2713 }
2714
2715 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
2716 if let Some(extension_store) = ExtensionStore::try_global(cx) {
2717 let (manifests, extensions_dir) = {
2718 let store = extension_store.read(cx);
2719 let installed = store.installed_extensions();
2720 let manifests: Vec<_> = installed
2721 .iter()
2722 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
2723 .collect();
2724 let extensions_dir = paths::extensions_dir().join("installed");
2725 (manifests, extensions_dir)
2726 };
2727
2728 self.project.update(cx, |project, cx| {
2729 project.agent_server_store().update(cx, |store, cx| {
2730 let manifest_refs: Vec<_> = manifests
2731 .iter()
2732 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2733 .collect();
2734 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2735 });
2736 });
2737 }
2738 }
2739
2740 pub fn new_agent_thread_with_external_source_prompt(
2741 &mut self,
2742 external_source_prompt: Option<ExternalSourcePrompt>,
2743 window: &mut Window,
2744 cx: &mut Context<Self>,
2745 ) {
2746 self.external_thread(
2747 None,
2748 None,
2749 None,
2750 None,
2751 external_source_prompt.map(AgentInitialContent::from),
2752 true,
2753 window,
2754 cx,
2755 );
2756 }
2757
2758 pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
2759 self.reset_start_thread_in_to_default(cx);
2760 self.new_agent_thread_inner(agent, true, window, cx);
2761 }
2762
2763 fn new_agent_thread_inner(
2764 &mut self,
2765 agent: Agent,
2766 focus: bool,
2767 window: &mut Window,
2768 cx: &mut Context<Self>,
2769 ) {
2770 let initial_content = self.take_active_initial_content(cx);
2771 self.external_thread(
2772 Some(agent),
2773 None,
2774 None,
2775 None,
2776 initial_content,
2777 focus,
2778 window,
2779 cx,
2780 );
2781 }
2782
2783 pub fn begin_loading_thread(&mut self) {
2784 self.pending_thread_loads += 1;
2785 }
2786
2787 pub fn load_agent_thread(
2788 &mut self,
2789 agent: Agent,
2790 session_id: acp::SessionId,
2791 work_dirs: Option<PathList>,
2792 title: Option<SharedString>,
2793 focus: bool,
2794 window: &mut Window,
2795 cx: &mut Context<Self>,
2796 ) {
2797 self.pending_thread_loads = self.pending_thread_loads.saturating_sub(1);
2798 if let Some(store) = ThreadMetadataStore::try_global(cx) {
2799 let thread_id = store
2800 .read(cx)
2801 .entry_by_session(&session_id)
2802 .map(|t| t.thread_id);
2803 if let Some(thread_id) = thread_id {
2804 store.update(cx, |store, cx| {
2805 store.unarchive(thread_id, cx);
2806 });
2807 }
2808 }
2809
2810 let has_session = |cv: &Entity<ConversationView>| -> bool {
2811 cv.read(cx)
2812 .active_thread()
2813 .is_some_and(|tv| tv.read(cx).thread.read(cx).session_id() == &session_id)
2814 };
2815
2816 // Check if the active view already has this session.
2817 if let BaseView::AgentThread { conversation_view } = &self.base_view {
2818 if has_session(conversation_view) {
2819 self.clear_overlay_state();
2820 cx.emit(AgentPanelEvent::ActiveViewChanged);
2821 return;
2822 }
2823 }
2824
2825 // Check if a retained thread has this session — promote it.
2826 let retained_key = self
2827 .retained_threads
2828 .iter()
2829 .find(|(_, cv)| has_session(cv))
2830 .map(|(id, _)| *id);
2831 if let Some(thread_id) = retained_key {
2832 if let Some(conversation_view) = self.retained_threads.remove(&thread_id) {
2833 self.set_base_view(
2834 BaseView::AgentThread { conversation_view },
2835 focus,
2836 window,
2837 cx,
2838 );
2839 self.remove_empty_draft(cx);
2840 return;
2841 }
2842 }
2843
2844 self.external_thread(
2845 Some(agent),
2846 Some(session_id),
2847 work_dirs,
2848 title,
2849 None,
2850 focus,
2851 window,
2852 cx,
2853 );
2854 self.remove_empty_draft(cx);
2855 }
2856
2857 pub(crate) fn create_agent_thread(
2858 &mut self,
2859 agent: Agent,
2860 resume_session_id: Option<acp::SessionId>,
2861 work_dirs: Option<PathList>,
2862 title: Option<SharedString>,
2863 initial_content: Option<AgentInitialContent>,
2864 window: &mut Window,
2865 cx: &mut Context<Self>,
2866 ) -> AgentThread {
2867 self.create_agent_thread_with_server(
2868 agent,
2869 None,
2870 resume_session_id,
2871 work_dirs,
2872 title,
2873 initial_content,
2874 window,
2875 cx,
2876 )
2877 }
2878
2879 fn create_agent_thread_with_server(
2880 &mut self,
2881 agent: Agent,
2882 server_override: Option<Rc<dyn AgentServer>>,
2883 resume_session_id: Option<acp::SessionId>,
2884 work_dirs: Option<PathList>,
2885 title: Option<SharedString>,
2886 initial_content: Option<AgentInitialContent>,
2887 window: &mut Window,
2888 cx: &mut Context<Self>,
2889 ) -> AgentThread {
2890 let existing_metadata = resume_session_id.as_ref().and_then(|sid| {
2891 ThreadMetadataStore::try_global(cx)
2892 .and_then(|store| store.read(cx).entry_by_session(sid).cloned())
2893 });
2894 let thread_id = existing_metadata
2895 .as_ref()
2896 .map(|m| m.thread_id)
2897 .unwrap_or_else(ThreadId::new);
2898 let workspace = self.workspace.clone();
2899 let project = self.project.clone();
2900 let mut worktree_paths = project.read(cx).worktree_paths(cx);
2901 if let Some(existing) = &existing_metadata {
2902 // When resuming a session (e.g. clicking a linked-worktree thread
2903 // in the sidebar), the current workspace's project may not have
2904 // completed its git scan yet. At that point `from_project()` would
2905 // compute main_worktree_paths from the raw folder path instead of
2906 // the git repo root, overwriting the thread's canonical project
2907 // group association. Preserve the existing main_worktree_paths so
2908 // the thread stays in the correct sidebar group.
2909 if !existing.main_worktree_paths().is_empty() {
2910 worktree_paths = WorktreePaths::from_path_lists(
2911 existing.main_worktree_paths().clone(),
2912 worktree_paths.folder_path_list().clone(),
2913 )
2914 .unwrap_or(worktree_paths);
2915 }
2916 }
2917 let remote_connection = project.read(cx).remote_connection_options(cx);
2918
2919 if resume_session_id.is_none() {
2920 let metadata = ThreadMetadata::new_draft(
2921 thread_id,
2922 agent.id(),
2923 title.clone(),
2924 worktree_paths,
2925 remote_connection,
2926 );
2927 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
2928 store.save(metadata, cx);
2929 });
2930 }
2931
2932 if self.selected_agent != agent {
2933 self.selected_agent = agent.clone();
2934 self.serialize(cx);
2935 }
2936
2937 cx.background_spawn({
2938 let kvp = KeyValueStore::global(cx);
2939 let agent = agent.clone();
2940 async move {
2941 write_global_last_used_agent(kvp, agent).await;
2942 }
2943 })
2944 .detach();
2945
2946 let server = server_override
2947 .unwrap_or_else(|| agent.server(self.fs.clone(), self.thread_store.clone()));
2948 let thread_store = server
2949 .clone()
2950 .downcast::<agent::NativeAgentServer>()
2951 .is_some()
2952 .then(|| self.thread_store.clone());
2953
2954 let connection_store = self.connection_store.clone();
2955
2956 let conversation_view = cx.new(|cx| {
2957 crate::ConversationView::new(
2958 server,
2959 connection_store,
2960 agent,
2961 resume_session_id,
2962 Some(thread_id),
2963 work_dirs,
2964 title,
2965 initial_content,
2966 workspace.clone(),
2967 project,
2968 thread_store,
2969 self.prompt_store.clone(),
2970 window,
2971 cx,
2972 )
2973 });
2974
2975 cx.observe(&conversation_view, |this, server_view, cx| {
2976 let is_active = this
2977 .active_conversation_view()
2978 .is_some_and(|active| active.entity_id() == server_view.entity_id());
2979 if is_active {
2980 cx.emit(AgentPanelEvent::ActiveViewChanged);
2981 this.serialize(cx);
2982 } else {
2983 cx.emit(AgentPanelEvent::RetainedThreadChanged);
2984 }
2985 cx.notify();
2986 })
2987 .detach();
2988
2989 AgentThread { conversation_view }
2990 }
2991
2992 fn active_thread_has_messages(&self, cx: &App) -> bool {
2993 self.active_agent_thread(cx)
2994 .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2995 }
2996
2997 pub fn active_thread_is_draft(&self, cx: &App) -> bool {
2998 self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
2999 }
3000
3001 fn handle_first_send_requested(
3002 &mut self,
3003 thread_view: Entity<ThreadView>,
3004 content: Vec<acp::ContentBlock>,
3005 window: &mut Window,
3006 cx: &mut Context<Self>,
3007 ) {
3008 match &self.start_thread_in {
3009 StartThreadIn::NewWorktree {
3010 worktree_name,
3011 branch_target,
3012 } => {
3013 self.handle_worktree_requested(
3014 content,
3015 WorktreeCreationArgs::New {
3016 worktree_name: worktree_name.clone(),
3017 branch_target: branch_target.clone(),
3018 },
3019 window,
3020 cx,
3021 );
3022 }
3023 StartThreadIn::LinkedWorktree { path, .. } => {
3024 self.handle_worktree_requested(
3025 content,
3026 WorktreeCreationArgs::Linked {
3027 worktree_path: path.clone(),
3028 },
3029 window,
3030 cx,
3031 );
3032 }
3033 StartThreadIn::LocalProject => {
3034 cx.defer_in(window, move |_this, window, cx| {
3035 thread_view.update(cx, |thread_view, cx| {
3036 let editor = thread_view.message_editor.clone();
3037 thread_view.send_impl(editor, window, cx);
3038 });
3039 });
3040 }
3041 }
3042 }
3043
3044 // TODO: The mapping from workspace root paths to git repositories needs a
3045 // unified approach across the codebase: this method, `sidebar::is_root_repo`,
3046 // thread persistence (which PathList is saved to the database), and thread
3047 // querying (which PathList is used to read threads back). All of these need
3048 // to agree on how repos are resolved for a given workspace, especially in
3049 // multi-root and nested-repo configurations.
3050 /// Partitions the project's visible worktrees into git-backed repositories
3051 /// and plain (non-git) paths. Git repos will have worktrees created for
3052 /// them; non-git paths are carried over to the new workspace as-is.
3053 ///
3054 /// When multiple worktrees map to the same repository, the most specific
3055 /// match wins (deepest work directory path), with a deterministic
3056 /// tie-break on entity id. Each repository appears at most once.
3057 fn classify_worktrees(
3058 &self,
3059 cx: &App,
3060 ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
3061 let project = &self.project;
3062 let repositories = project.read(cx).repositories(cx).clone();
3063 let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
3064 let mut non_git_paths: Vec<PathBuf> = Vec::new();
3065 let mut seen_repo_ids = std::collections::HashSet::new();
3066
3067 for worktree in project.read(cx).visible_worktrees(cx) {
3068 let wt_path = worktree.read(cx).abs_path();
3069
3070 let matching_repo = repositories
3071 .iter()
3072 .filter_map(|(id, repo)| {
3073 let work_dir = repo.read(cx).work_directory_abs_path.clone();
3074 if wt_path.starts_with(work_dir.as_ref()) {
3075 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
3076 } else {
3077 None
3078 }
3079 })
3080 .max_by(
3081 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
3082 left_depth
3083 .cmp(right_depth)
3084 .then_with(|| left_id.cmp(right_id))
3085 },
3086 );
3087
3088 if let Some((id, repo, _)) = matching_repo {
3089 if seen_repo_ids.insert(id) {
3090 git_repos.push(repo);
3091 }
3092 } else {
3093 non_git_paths.push(wt_path.to_path_buf());
3094 }
3095 }
3096
3097 (git_repos, non_git_paths)
3098 }
3099
3100 fn resolve_worktree_branch_target(
3101 branch_target: &NewWorktreeBranchTarget,
3102 existing_branches: &HashSet<String>,
3103 occupied_branches: &HashSet<String>,
3104 ) -> Result<(String, bool, Option<String>)> {
3105 let generate_branch_name = || -> Result<String> {
3106 let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
3107 let mut rng = rand::rng();
3108 crate::branch_names::generate_branch_name(&refs, &mut rng)
3109 .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
3110 };
3111
3112 match branch_target {
3113 NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
3114 Ok((name.clone(), false, from_ref.clone()))
3115 }
3116 NewWorktreeBranchTarget::ExistingBranch { name } => {
3117 if occupied_branches.contains(name) {
3118 Ok((generate_branch_name()?, false, Some(name.clone())))
3119 } else {
3120 Ok((name.clone(), true, None))
3121 }
3122 }
3123 NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
3124 }
3125 }
3126
3127 /// Kicks off an async git-worktree creation for each repository. Returns:
3128 ///
3129 /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
3130 /// receiver resolves once the git worktree command finishes.
3131 /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
3132 /// later to remap open editor tabs into the new workspace.
3133 fn start_worktree_creations(
3134 git_repos: &[Entity<project::git_store::Repository>],
3135 worktree_name: Option<String>,
3136 branch_name: &str,
3137 use_existing_branch: bool,
3138 start_point: Option<String>,
3139 worktree_directory_setting: &str,
3140 cx: &mut Context<Self>,
3141 ) -> Result<(
3142 Vec<(
3143 Entity<project::git_store::Repository>,
3144 PathBuf,
3145 futures::channel::oneshot::Receiver<Result<()>>,
3146 )>,
3147 Vec<(PathBuf, PathBuf)>,
3148 )> {
3149 let mut creation_infos = Vec::new();
3150 let mut path_remapping = Vec::new();
3151
3152 let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
3153
3154 for repo in git_repos {
3155 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
3156 let new_path =
3157 repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
3158 let target = if use_existing_branch {
3159 debug_assert!(
3160 git_repos.len() == 1,
3161 "use_existing_branch should only be true for a single repo"
3162 );
3163 git::repository::CreateWorktreeTarget::ExistingBranch {
3164 branch_name: branch_name.to_string(),
3165 }
3166 } else {
3167 git::repository::CreateWorktreeTarget::NewBranch {
3168 branch_name: branch_name.to_string(),
3169 base_sha: start_point.clone(),
3170 }
3171 };
3172 let receiver = repo.create_worktree(target, new_path.clone());
3173 let work_dir = repo.work_directory_abs_path.clone();
3174 anyhow::Ok((work_dir, new_path, receiver))
3175 })?;
3176 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
3177 creation_infos.push((repo.clone(), new_path, receiver));
3178 }
3179
3180 Ok((creation_infos, path_remapping))
3181 }
3182
3183 /// Waits for every in-flight worktree creation to complete. If any
3184 /// creation fails, all successfully-created worktrees are rolled back
3185 /// (removed) so the project isn't left in a half-migrated state.
3186 async fn await_and_rollback_on_failure(
3187 creation_infos: Vec<(
3188 Entity<project::git_store::Repository>,
3189 PathBuf,
3190 futures::channel::oneshot::Receiver<Result<()>>,
3191 )>,
3192 fs: Arc<dyn Fs>,
3193 cx: &mut AsyncWindowContext,
3194 ) -> Result<Vec<PathBuf>> {
3195 let mut created_paths: Vec<PathBuf> = Vec::new();
3196 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
3197 Vec::new();
3198 let mut first_error: Option<anyhow::Error> = None;
3199
3200 for (repo, new_path, receiver) in creation_infos {
3201 repos_and_paths.push((repo.clone(), new_path.clone()));
3202 match receiver.await {
3203 Ok(Ok(())) => {
3204 created_paths.push(new_path);
3205 }
3206 Ok(Err(err)) => {
3207 if first_error.is_none() {
3208 first_error = Some(err);
3209 }
3210 }
3211 Err(_canceled) => {
3212 if first_error.is_none() {
3213 first_error = Some(anyhow!("Worktree creation was canceled"));
3214 }
3215 }
3216 }
3217 }
3218
3219 let Some(err) = first_error else {
3220 return Ok(created_paths);
3221 };
3222
3223 // Rollback all attempted worktrees (both successful and failed)
3224 let mut rollback_futures = Vec::new();
3225 for (rollback_repo, rollback_path) in &repos_and_paths {
3226 let receiver = cx
3227 .update(|_, cx| {
3228 rollback_repo.update(cx, |repo, _cx| {
3229 repo.remove_worktree(rollback_path.clone(), true)
3230 })
3231 })
3232 .ok();
3233
3234 rollback_futures.push((rollback_path.clone(), receiver));
3235 }
3236
3237 let mut rollback_failures: Vec<String> = Vec::new();
3238 for (path, receiver_opt) in rollback_futures {
3239 let mut git_remove_failed = false;
3240
3241 if let Some(receiver) = receiver_opt {
3242 match receiver.await {
3243 Ok(Ok(())) => {}
3244 Ok(Err(rollback_err)) => {
3245 log::error!(
3246 "git worktree remove failed for {}: {rollback_err}",
3247 path.display()
3248 );
3249 git_remove_failed = true;
3250 }
3251 Err(canceled) => {
3252 log::error!(
3253 "git worktree remove failed for {}: {canceled}",
3254 path.display()
3255 );
3256 git_remove_failed = true;
3257 }
3258 }
3259 } else {
3260 log::error!(
3261 "failed to dispatch git worktree remove for {}",
3262 path.display()
3263 );
3264 git_remove_failed = true;
3265 }
3266
3267 // `git worktree remove` normally removes this directory, but since
3268 // `git worktree remove` failed (or wasn't dispatched), manually rm the directory.
3269 if git_remove_failed {
3270 if let Err(fs_err) = fs
3271 .remove_dir(
3272 &path,
3273 fs::RemoveOptions {
3274 recursive: true,
3275 ignore_if_not_exists: true,
3276 },
3277 )
3278 .await
3279 {
3280 let msg = format!("{}: failed to remove directory: {fs_err}", path.display());
3281 log::error!("{}", msg);
3282 rollback_failures.push(msg);
3283 }
3284 }
3285 }
3286 let mut error_message = format!("Failed to create worktree: {err}");
3287 if !rollback_failures.is_empty() {
3288 error_message.push_str("\n\nFailed to clean up: ");
3289 error_message.push_str(&rollback_failures.join(", "));
3290 }
3291 Err(anyhow!(error_message))
3292 }
3293
3294 fn set_worktree_creation_error(
3295 &mut self,
3296 message: SharedString,
3297 window: &mut Window,
3298 cx: &mut Context<Self>,
3299 ) {
3300 if let Some((_, status)) = &mut self.worktree_creation_status {
3301 *status = WorktreeCreationStatus::Error(message);
3302 }
3303 if matches!(self.base_view, BaseView::Uninitialized) {
3304 let selected_agent = self.selected_agent.clone();
3305 self.new_agent_thread(selected_agent, window, cx);
3306 }
3307 cx.notify();
3308 }
3309
3310 fn handle_worktree_requested(
3311 &mut self,
3312 content: Vec<acp::ContentBlock>,
3313 args: WorktreeCreationArgs,
3314 window: &mut Window,
3315 cx: &mut Context<Self>,
3316 ) {
3317 if matches!(
3318 self.worktree_creation_status,
3319 Some((_, WorktreeCreationStatus::Creating))
3320 ) {
3321 return;
3322 }
3323
3324 let conversation_view_id = self
3325 .active_conversation_view()
3326 .map(|v| v.entity_id())
3327 .unwrap_or_else(|| EntityId::from(0u64));
3328 self.worktree_creation_status =
3329 Some((conversation_view_id, WorktreeCreationStatus::Creating));
3330 cx.notify();
3331
3332 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
3333
3334 if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
3335 self.set_worktree_creation_error(
3336 "No git repositories found in the project".into(),
3337 window,
3338 cx,
3339 );
3340 return;
3341 }
3342
3343 let (branch_receivers, worktree_receivers, worktree_directory_setting) =
3344 if matches!(args, WorktreeCreationArgs::New { .. }) {
3345 (
3346 Some(
3347 git_repos
3348 .iter()
3349 .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
3350 .collect::<Vec<_>>(),
3351 ),
3352 Some(
3353 git_repos
3354 .iter()
3355 .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
3356 .collect::<Vec<_>>(),
3357 ),
3358 Some(
3359 ProjectSettings::get_global(cx)
3360 .git
3361 .worktree_directory
3362 .clone(),
3363 ),
3364 )
3365 } else {
3366 (None, None, None)
3367 };
3368
3369 let active_file_path = self.workspace.upgrade().and_then(|workspace| {
3370 let workspace = workspace.read(cx);
3371 let active_item = workspace.active_item(cx)?;
3372 let project_path = active_item.project_path(cx)?;
3373 workspace
3374 .project()
3375 .read(cx)
3376 .absolute_path(&project_path, cx)
3377 });
3378
3379 let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
3380
3381 if remote_connection_options.is_some() {
3382 let is_disconnected = self
3383 .project
3384 .read(cx)
3385 .remote_client()
3386 .is_some_and(|client| client.read(cx).is_disconnected());
3387 if is_disconnected {
3388 self.set_worktree_creation_error(
3389 "Cannot create worktree: remote connection is not active".into(),
3390 window,
3391 cx,
3392 );
3393 return;
3394 }
3395 }
3396
3397 let workspace = self.workspace.clone();
3398 let window_handle = window
3399 .window_handle()
3400 .downcast::<workspace::MultiWorkspace>();
3401
3402 let selected_agent = self.selected_agent();
3403
3404 let task = cx.spawn_in(window, async move |this, cx| {
3405 let (all_paths, path_remapping, has_non_git) = match args {
3406 WorktreeCreationArgs::New {
3407 worktree_name,
3408 branch_target,
3409 } => {
3410 let branch_receivers = branch_receivers
3411 .expect("branch receivers must be prepared for new worktree creation");
3412 let worktree_receivers = worktree_receivers
3413 .expect("worktree receivers must be prepared for new worktree creation");
3414 let worktree_directory_setting = worktree_directory_setting
3415 .expect("worktree directory must be prepared for new worktree creation");
3416
3417 let mut existing_branches = HashSet::default();
3418 for result in futures::future::join_all(branch_receivers).await {
3419 match result {
3420 Ok(Ok(branches)) => {
3421 for branch in branches {
3422 existing_branches.insert(branch.name().to_string());
3423 }
3424 }
3425 Ok(Err(err)) => {
3426 Err::<(), _>(err).log_err();
3427 }
3428 Err(_) => {}
3429 }
3430 }
3431
3432 let mut occupied_branches = HashSet::default();
3433 for result in futures::future::join_all(worktree_receivers).await {
3434 match result {
3435 Ok(Ok(worktrees)) => {
3436 for worktree in worktrees {
3437 if let Some(branch_name) = worktree.branch_name() {
3438 occupied_branches.insert(branch_name.to_string());
3439 }
3440 }
3441 }
3442 Ok(Err(err)) => {
3443 Err::<(), _>(err).log_err();
3444 }
3445 Err(_) => {}
3446 }
3447 }
3448
3449 let (branch_name, use_existing_branch, start_point) =
3450 match Self::resolve_worktree_branch_target(
3451 &branch_target,
3452 &existing_branches,
3453 &occupied_branches,
3454 ) {
3455 Ok(target) => target,
3456 Err(err) => {
3457 this.update_in(cx, |this, window, cx| {
3458 this.set_worktree_creation_error(
3459 err.to_string().into(),
3460 window,
3461 cx,
3462 );
3463 })?;
3464 return anyhow::Ok(());
3465 }
3466 };
3467
3468 let (creation_infos, path_remapping) =
3469 match this.update_in(cx, |_this, _window, cx| {
3470 Self::start_worktree_creations(
3471 &git_repos,
3472 worktree_name,
3473 &branch_name,
3474 use_existing_branch,
3475 start_point,
3476 &worktree_directory_setting,
3477 cx,
3478 )
3479 }) {
3480 Ok(Ok(result)) => result,
3481 Ok(Err(err)) | Err(err) => {
3482 this.update_in(cx, |this, window, cx| {
3483 this.set_worktree_creation_error(
3484 format!("Failed to validate worktree directory: {err}")
3485 .into(),
3486 window,
3487 cx,
3488 );
3489 })
3490 .log_err();
3491 return anyhow::Ok(());
3492 }
3493 };
3494
3495 let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
3496
3497 let created_paths =
3498 match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await {
3499 Ok(paths) => paths,
3500 Err(err) => {
3501 this.update_in(cx, |this, window, cx| {
3502 this.set_worktree_creation_error(
3503 format!("{err}").into(),
3504 window,
3505 cx,
3506 );
3507 })?;
3508 return anyhow::Ok(());
3509 }
3510 };
3511
3512 let mut all_paths = created_paths;
3513 let has_non_git = !non_git_paths.is_empty();
3514 all_paths.extend(non_git_paths.iter().cloned());
3515 (all_paths, path_remapping, has_non_git)
3516 }
3517 WorktreeCreationArgs::Linked { worktree_path } => {
3518 let mut all_paths = vec![worktree_path];
3519 let has_non_git = !non_git_paths.is_empty();
3520 all_paths.extend(non_git_paths.iter().cloned());
3521 (all_paths, Vec::new(), has_non_git)
3522 }
3523 };
3524
3525 if workspace.upgrade().is_none() {
3526 this.update_in(cx, |this, window, cx| {
3527 this.set_worktree_creation_error(
3528 "Workspace no longer available".into(),
3529 window,
3530 cx,
3531 );
3532 })?;
3533 return anyhow::Ok(());
3534 }
3535
3536 let this_for_error = this.clone();
3537 if let Err(err) = Self::open_worktree_workspace_and_start_thread(
3538 this,
3539 all_paths,
3540 window_handle,
3541 active_file_path,
3542 path_remapping,
3543 non_git_paths,
3544 has_non_git,
3545 content,
3546 selected_agent,
3547 remote_connection_options,
3548 cx,
3549 )
3550 .await
3551 {
3552 this_for_error
3553 .update_in(cx, |this, window, cx| {
3554 this.set_worktree_creation_error(
3555 format!("Failed to set up workspace: {err}").into(),
3556 window,
3557 cx,
3558 );
3559 })
3560 .log_err();
3561 }
3562 anyhow::Ok(())
3563 });
3564
3565 self._worktree_creation_task = Some(cx.background_spawn(async move {
3566 task.await.log_err();
3567 }));
3568 }
3569
3570 async fn open_worktree_workspace_and_start_thread(
3571 this: WeakEntity<Self>,
3572 all_paths: Vec<PathBuf>,
3573 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
3574 active_file_path: Option<PathBuf>,
3575 path_remapping: Vec<(PathBuf, PathBuf)>,
3576 non_git_paths: Vec<PathBuf>,
3577 has_non_git: bool,
3578 content: Vec<acp::ContentBlock>,
3579 selected_agent: Option<Agent>,
3580 remote_connection_options: Option<RemoteConnectionOptions>,
3581 cx: &mut AsyncWindowContext,
3582 ) -> Result<()> {
3583 let window_handle = window_handle
3584 .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
3585
3586 let (workspace_task, modal_workspace) =
3587 window_handle.update(cx, |multi_workspace, window, cx| {
3588 let path_list = PathList::new(&all_paths);
3589 let active_workspace = multi_workspace.workspace().clone();
3590 let modal_workspace = active_workspace.clone();
3591
3592 let task = multi_workspace.find_or_create_workspace(
3593 path_list,
3594 remote_connection_options,
3595 None,
3596 move |connection_options, window, cx| {
3597 remote_connection::connect_with_modal(
3598 &active_workspace,
3599 connection_options,
3600 window,
3601 cx,
3602 )
3603 },
3604 window,
3605 cx,
3606 );
3607 (task, modal_workspace)
3608 })?;
3609
3610 let result = workspace_task.await;
3611 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
3612 let new_workspace = result?;
3613
3614 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
3615
3616 if let Some(task) = panels_task {
3617 task.await.log_err();
3618 }
3619
3620 new_workspace
3621 .update(cx, |workspace, cx| {
3622 workspace.project().read(cx).wait_for_initial_scan(cx)
3623 })
3624 .await;
3625
3626 new_workspace
3627 .update(cx, |workspace, cx| {
3628 let repos = workspace
3629 .project()
3630 .read(cx)
3631 .repositories(cx)
3632 .values()
3633 .cloned()
3634 .collect::<Vec<_>>();
3635
3636 let tasks = repos
3637 .into_iter()
3638 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
3639 futures::future::join_all(tasks)
3640 })
3641 .await;
3642
3643 let initial_content = AgentInitialContent::ContentBlock {
3644 blocks: content,
3645 auto_submit: true,
3646 };
3647
3648 window_handle.update(cx, |_multi_workspace, window, cx| {
3649 new_workspace.update(cx, |workspace, cx| {
3650 if has_non_git {
3651 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
3652 workspace.show_toast(
3653 workspace::Toast::new(
3654 toast_id,
3655 "Some project folders are not git repositories. \
3656 They were included as-is without creating a worktree.",
3657 ),
3658 cx,
3659 );
3660 }
3661
3662 // If we had an active buffer, remap its path and reopen it.
3663 let had_active_file = active_file_path.is_some();
3664 let remapped_active_path = active_file_path.and_then(|original_path| {
3665 let best_match = path_remapping
3666 .iter()
3667 .filter_map(|(old_root, new_root)| {
3668 original_path.strip_prefix(old_root).ok().map(|relative| {
3669 (old_root.components().count(), new_root.join(relative))
3670 })
3671 })
3672 .max_by_key(|(depth, _)| *depth);
3673
3674 if let Some((_, remapped_path)) = best_match {
3675 return Some(remapped_path);
3676 }
3677
3678 for non_git in &non_git_paths {
3679 if original_path.starts_with(non_git) {
3680 return Some(original_path);
3681 }
3682 }
3683 None
3684 });
3685
3686 if had_active_file && remapped_active_path.is_none() {
3687 log::warn!(
3688 "Active file could not be remapped to the new worktree; it will not be reopened"
3689 );
3690 }
3691
3692 if let Some(path) = remapped_active_path {
3693 let open_task = workspace.open_paths(
3694 vec![path],
3695 workspace::OpenOptions::default(),
3696 None,
3697 window,
3698 cx,
3699 );
3700 cx.spawn(async move |_, _| -> anyhow::Result<()> {
3701 for item in open_task.await.into_iter().flatten() {
3702 item?;
3703 }
3704 Ok(())
3705 })
3706 .detach_and_log_err(cx);
3707 }
3708
3709 workspace.focus_panel::<AgentPanel>(window, cx);
3710
3711 // If no active buffer was open, zoom the agent panel
3712 // (equivalent to cmd-esc fullscreen behavior).
3713 // This must happen after focus_panel, which activates
3714 // and opens the panel in the dock.
3715
3716 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3717 panel.update(cx, |panel, cx| {
3718 panel.external_thread(
3719 selected_agent,
3720 None,
3721 None,
3722 None,
3723 Some(initial_content),
3724 true,
3725 window,
3726 cx,
3727 );
3728 });
3729 }
3730 });
3731 })?;
3732
3733 window_handle.update(cx, |multi_workspace, window, cx| {
3734 multi_workspace.activate(new_workspace.clone(), window, cx);
3735
3736 new_workspace.update(cx, |workspace, cx| {
3737 workspace.run_create_worktree_tasks(window, cx);
3738 })
3739 })?;
3740
3741 this.update_in(cx, |this, window, cx| {
3742 this.worktree_creation_status = None;
3743
3744 if let Some(thread_view) = this.active_thread_view(cx) {
3745 thread_view.update(cx, |thread_view, cx| {
3746 thread_view
3747 .message_editor
3748 .update(cx, |editor, cx| editor.clear(window, cx));
3749 });
3750 }
3751
3752 this.start_thread_in = StartThreadIn::LocalProject;
3753 this.serialize(cx);
3754 cx.notify();
3755 })?;
3756
3757 anyhow::Ok(())
3758 }
3759}
3760
3761impl Focusable for AgentPanel {
3762 fn focus_handle(&self, cx: &App) -> FocusHandle {
3763 match self.visible_surface() {
3764 VisibleSurface::Uninitialized => self.focus_handle.clone(),
3765 VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx),
3766 VisibleSurface::History(view) => view.read(cx).focus_handle(cx),
3767 VisibleSurface::Configuration(configuration) => {
3768 if let Some(configuration) = configuration {
3769 configuration.focus_handle(cx)
3770 } else {
3771 self.focus_handle.clone()
3772 }
3773 }
3774 }
3775 }
3776}
3777
3778fn agent_panel_dock_position(cx: &App) -> DockPosition {
3779 AgentSettings::get_global(cx).dock.into()
3780}
3781
3782pub enum AgentPanelEvent {
3783 ActiveViewChanged,
3784 ThreadFocused,
3785 RetainedThreadChanged,
3786 MessageSentOrQueued { thread_id: ThreadId },
3787}
3788
3789impl EventEmitter<PanelEvent> for AgentPanel {}
3790impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3791
3792impl Panel for AgentPanel {
3793 fn persistent_name() -> &'static str {
3794 "AgentPanel"
3795 }
3796
3797 fn panel_key() -> &'static str {
3798 AGENT_PANEL_KEY
3799 }
3800
3801 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3802 agent_panel_dock_position(cx)
3803 }
3804
3805 fn position_is_valid(&self, position: DockPosition) -> bool {
3806 position != DockPosition::Bottom
3807 }
3808
3809 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
3810 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3811 settings
3812 .agent
3813 .get_or_insert_default()
3814 .set_dock(position.into());
3815 });
3816 }
3817
3818 fn default_size(&self, window: &Window, cx: &App) -> Pixels {
3819 let settings = AgentSettings::get_global(cx);
3820 match self.position(window, cx) {
3821 DockPosition::Left | DockPosition::Right => settings.default_width,
3822 DockPosition::Bottom => settings.default_height,
3823 }
3824 }
3825
3826 fn min_size(&self, window: &Window, cx: &App) -> Option<Pixels> {
3827 match self.position(window, cx) {
3828 DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH),
3829 DockPosition::Bottom => None,
3830 }
3831 }
3832
3833 fn supports_flexible_size(&self) -> bool {
3834 true
3835 }
3836
3837 fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
3838 AgentSettings::get_global(cx).flexible
3839 }
3840
3841 fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
3842 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3843 settings
3844 .agent
3845 .get_or_insert_default()
3846 .set_flexible_size(flexible);
3847 });
3848 }
3849
3850 fn set_active(&mut self, _active: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
3851
3852 fn remote_id() -> Option<proto::PanelId> {
3853 Some(proto::PanelId::AssistantPanel)
3854 }
3855
3856 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3857 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3858 }
3859
3860 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3861 Some("Agent Panel")
3862 }
3863
3864 fn toggle_action(&self) -> Box<dyn Action> {
3865 Box::new(ToggleFocus)
3866 }
3867
3868 fn activation_priority(&self) -> u32 {
3869 0
3870 }
3871
3872 fn enabled(&self, cx: &App) -> bool {
3873 AgentSettings::get_global(cx).enabled(cx)
3874 }
3875
3876 fn is_agent_panel(&self) -> bool {
3877 true
3878 }
3879
3880 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3881 self.zoomed
3882 }
3883
3884 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3885 self.zoomed = zoomed;
3886 cx.notify();
3887 }
3888}
3889
3890impl AgentPanel {
3891 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3892 let content = match self.visible_surface() {
3893 VisibleSurface::AgentThread(conversation_view) => {
3894 let server_view_ref = conversation_view.read(cx);
3895 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3896 && server_view_ref.root_thread(cx).map_or(false, |tv| {
3897 tv.read(cx).thread.read(cx).has_provisional_title()
3898 });
3899
3900 if let Some(title_editor) = server_view_ref
3901 .root_thread(cx)
3902 .map(|r| r.read(cx).title_editor.clone())
3903 {
3904 if is_generating_title {
3905 Label::new(DEFAULT_THREAD_TITLE)
3906 .color(Color::Muted)
3907 .truncate()
3908 .with_animation(
3909 "generating_title",
3910 Animation::new(Duration::from_secs(2))
3911 .repeat()
3912 .with_easing(pulsating_between(0.4, 0.8)),
3913 |label, delta| label.alpha(delta),
3914 )
3915 .into_any_element()
3916 } else {
3917 div()
3918 .w_full()
3919 .on_action({
3920 let conversation_view = conversation_view.downgrade();
3921 move |_: &menu::Confirm, window, cx| {
3922 if let Some(conversation_view) = conversation_view.upgrade() {
3923 conversation_view.focus_handle(cx).focus(window, cx);
3924 }
3925 }
3926 })
3927 .on_action({
3928 let conversation_view = conversation_view.downgrade();
3929 move |_: &editor::actions::Cancel, window, cx| {
3930 if let Some(conversation_view) = conversation_view.upgrade() {
3931 conversation_view.focus_handle(cx).focus(window, cx);
3932 }
3933 }
3934 })
3935 .child(title_editor)
3936 .into_any_element()
3937 }
3938 } else {
3939 Label::new(conversation_view.read(cx).title(cx))
3940 .color(Color::Muted)
3941 .truncate()
3942 .into_any_element()
3943 }
3944 }
3945 VisibleSurface::History(_) => Label::new("History").truncate().into_any_element(),
3946 VisibleSurface::Configuration(_) => {
3947 Label::new("Settings").truncate().into_any_element()
3948 }
3949 VisibleSurface::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3950 };
3951
3952 h_flex()
3953 .key_context("TitleEditor")
3954 .id("TitleEditor")
3955 .flex_grow()
3956 .w_full()
3957 .max_w_full()
3958 .overflow_x_scroll()
3959 .child(content)
3960 .into_any()
3961 }
3962
3963 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3964 conversation_view.update(cx, |conversation_view, cx| {
3965 if let Some(thread) = conversation_view.as_native_thread(cx) {
3966 thread.update(cx, |thread, cx| {
3967 thread.generate_title(cx);
3968 });
3969 }
3970 });
3971 }
3972
3973 fn render_panel_options_menu(
3974 &self,
3975 _window: &mut Window,
3976 cx: &mut Context<Self>,
3977 ) -> impl IntoElement {
3978 let focus_handle = self.focus_handle(cx);
3979
3980 let conversation_view = match &self.base_view {
3981 BaseView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3982 _ => None,
3983 };
3984 let thread_with_messages = match &self.base_view {
3985 BaseView::AgentThread { conversation_view } => {
3986 conversation_view.read(cx).has_user_submitted_prompt(cx)
3987 }
3988 _ => false,
3989 };
3990 let has_auth_methods = match &self.base_view {
3991 BaseView::AgentThread { conversation_view } => {
3992 conversation_view.read(cx).has_auth_methods()
3993 }
3994 _ => false,
3995 };
3996
3997 PopoverMenu::new("agent-options-menu")
3998 .trigger_with_tooltip(
3999 IconButton::new("agent-options-menu", IconName::Ellipsis)
4000 .icon_size(IconSize::Small),
4001 {
4002 let focus_handle = focus_handle.clone();
4003 move |_window, cx| {
4004 Tooltip::for_action_in(
4005 "Toggle Agent Menu",
4006 &ToggleOptionsMenu,
4007 &focus_handle,
4008 cx,
4009 )
4010 }
4011 },
4012 )
4013 .anchor(Corner::TopRight)
4014 .with_handle(self.agent_panel_menu_handle.clone())
4015 .menu({
4016 move |window, cx| {
4017 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
4018 menu = menu.context(focus_handle.clone());
4019
4020 if thread_with_messages {
4021 menu = menu.header("Current Thread");
4022
4023 if let Some(conversation_view) = conversation_view.as_ref() {
4024 menu = menu
4025 .entry("Regenerate Thread Title", None, {
4026 let conversation_view = conversation_view.clone();
4027 move |_, cx| {
4028 Self::handle_regenerate_thread_title(
4029 conversation_view.clone(),
4030 cx,
4031 );
4032 }
4033 })
4034 .separator();
4035 }
4036 }
4037
4038 menu = menu
4039 .header("MCP Servers")
4040 .action(
4041 "View Server Extensions",
4042 Box::new(zed_actions::Extensions {
4043 category_filter: Some(
4044 zed_actions::ExtensionCategoryFilter::ContextServers,
4045 ),
4046 id: None,
4047 }),
4048 )
4049 .action("Add Custom Server…", Box::new(AddContextServer))
4050 .separator()
4051 .action("Rules", Box::new(OpenRulesLibrary::default()))
4052 .action("Profiles", Box::new(ManageProfiles::default()))
4053 .action("Settings", Box::new(OpenSettings))
4054 .separator()
4055 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
4056
4057 if has_auth_methods {
4058 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
4059 }
4060
4061 menu
4062 }))
4063 }
4064 })
4065 }
4066
4067 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4068 let focus_handle = self.focus_handle(cx);
4069
4070 IconButton::new("go-back", IconName::ArrowLeft)
4071 .icon_size(IconSize::Small)
4072 .on_click(cx.listener(|this, _, window, cx| {
4073 this.go_back(&workspace::GoBack, window, cx);
4074 }))
4075 .tooltip({
4076 move |_window, cx| {
4077 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
4078 }
4079 })
4080 }
4081
4082 fn project_has_git_repository(&self, cx: &App) -> bool {
4083 !self.project.read(cx).repositories(cx).is_empty()
4084 }
4085
4086 fn is_active_view_creating_worktree(&self, _cx: &App) -> bool {
4087 match &self.worktree_creation_status {
4088 Some((view_id, WorktreeCreationStatus::Creating)) => {
4089 self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id)
4090 }
4091 _ => false,
4092 }
4093 }
4094
4095 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
4096 let focus_handle = self.focus_handle(cx);
4097
4098 let is_creating = self.is_active_view_creating_worktree(cx);
4099
4100 let trigger_parts = self
4101 .start_thread_in
4102 .trigger_label(self.project.read(cx), cx);
4103
4104 let icon = if self.start_thread_in_menu_handle.is_deployed() {
4105 IconName::ChevronUp
4106 } else {
4107 IconName::ChevronDown
4108 };
4109
4110 let trigger_button = ButtonLike::new("thread-target-trigger")
4111 .disabled(is_creating)
4112 .when_some(trigger_parts.prefix, |this, prefix| {
4113 this.child(Label::new(prefix).color(Color::Muted))
4114 })
4115 .child(Label::new(trigger_parts.label))
4116 .when_some(trigger_parts.suffix, |this, suffix| {
4117 this.child(Label::new(suffix).color(Color::Muted))
4118 })
4119 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
4120
4121 let project = self.project.clone();
4122 let current_target = self.start_thread_in.clone();
4123 let fs = self.fs.clone();
4124
4125 PopoverMenu::new("thread-target-selector")
4126 .trigger_with_tooltip(trigger_button, {
4127 move |_window, cx| {
4128 Tooltip::for_action_in(
4129 "Start Thread In…",
4130 &CycleStartThreadIn,
4131 &focus_handle,
4132 cx,
4133 )
4134 }
4135 })
4136 .menu(move |window, cx| {
4137 let fs = fs.clone();
4138 Some(cx.new(|cx| {
4139 ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
4140 }))
4141 })
4142 .with_handle(self.start_thread_in_menu_handle.clone())
4143 .anchor(Corner::TopLeft)
4144 .offset(gpui::Point {
4145 x: px(1.0),
4146 y: px(1.0),
4147 })
4148 }
4149
4150 fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
4151 let is_creating = self.is_active_view_creating_worktree(cx);
4152
4153 let project_ref = self.project.read(cx);
4154 let trigger_parts = self
4155 .start_thread_in
4156 .branch_trigger_label(project_ref, cx)
4157 .unwrap_or_else(|| StartThreadInLabel {
4158 prefix: Some("From:".into()),
4159 label: "HEAD".into(),
4160 suffix: None,
4161 });
4162
4163 let icon = if self.thread_branch_menu_handle.is_deployed() {
4164 IconName::ChevronUp
4165 } else {
4166 IconName::ChevronDown
4167 };
4168
4169 let trigger_button = ButtonLike::new("thread-branch-trigger")
4170 .disabled(is_creating)
4171 .when_some(trigger_parts.prefix, |this, prefix| {
4172 this.child(Label::new(prefix).color(Color::Muted))
4173 })
4174 .child(Label::new(trigger_parts.label))
4175 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
4176
4177 let project = self.project.clone();
4178 let current_target = self.start_thread_in.clone();
4179
4180 PopoverMenu::new("thread-branch-selector")
4181 .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
4182 .menu(move |window, cx| {
4183 Some(cx.new(|cx| {
4184 ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
4185 }))
4186 })
4187 .with_handle(self.thread_branch_menu_handle.clone())
4188 .anchor(Corner::TopLeft)
4189 .offset(gpui::Point {
4190 x: px(1.0),
4191 y: px(1.0),
4192 })
4193 }
4194
4195 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4196 let agent_server_store = self.project.read(cx).agent_server_store().clone();
4197 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
4198 let focus_handle = self.focus_handle(cx);
4199
4200 let (selected_agent_custom_icon, selected_agent_label) =
4201 if let Agent::Custom { id, .. } = &self.selected_agent {
4202 let store = agent_server_store.read(cx);
4203 let icon = store.agent_icon(&id);
4204
4205 let label = store
4206 .agent_display_name(&id)
4207 .unwrap_or_else(|| self.selected_agent.label());
4208 (icon, label)
4209 } else {
4210 (None, self.selected_agent.label())
4211 };
4212
4213 let active_thread = match &self.base_view {
4214 BaseView::AgentThread { conversation_view } => {
4215 conversation_view.read(cx).as_native_thread(cx)
4216 }
4217 BaseView::Uninitialized => None,
4218 };
4219
4220 let new_thread_menu_builder: Rc<
4221 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
4222 > = {
4223 let selected_agent = self.selected_agent.clone();
4224 let is_agent_selected = move |agent: Agent| selected_agent == agent;
4225
4226 let workspace = self.workspace.clone();
4227 let is_via_collab = workspace
4228 .update(cx, |workspace, cx| {
4229 workspace.project().read(cx).is_via_collab()
4230 })
4231 .unwrap_or_default();
4232
4233 let focus_handle = focus_handle.clone();
4234 let agent_server_store = agent_server_store;
4235
4236 Rc::new(move |window, cx| {
4237 telemetry::event!("New Thread Clicked");
4238
4239 let active_thread = active_thread.clone();
4240 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
4241 menu.context(focus_handle.clone())
4242 .when_some(active_thread, |this, active_thread| {
4243 let thread = active_thread.read(cx);
4244
4245 if !thread.is_empty() {
4246 let session_id = thread.id().clone();
4247 this.item(
4248 ContextMenuEntry::new("New From Summary")
4249 .icon(IconName::ThreadFromSummary)
4250 .icon_color(Color::Muted)
4251 .handler(move |window, cx| {
4252 window.dispatch_action(
4253 Box::new(NewNativeAgentThreadFromSummary {
4254 from_session_id: session_id.clone(),
4255 }),
4256 cx,
4257 );
4258 }),
4259 )
4260 } else {
4261 this
4262 }
4263 })
4264 .item(
4265 ContextMenuEntry::new("Zed Agent")
4266 .when(is_agent_selected(Agent::NativeAgent), |this| {
4267 this.action(Box::new(NewExternalAgentThread { agent: None }))
4268 })
4269 .icon(IconName::ZedAgent)
4270 .icon_color(Color::Muted)
4271 .handler({
4272 let workspace = workspace.clone();
4273 move |window, cx| {
4274 if let Some(workspace) = workspace.upgrade() {
4275 workspace.update(cx, |workspace, cx| {
4276 if let Some(panel) =
4277 workspace.panel::<AgentPanel>(cx)
4278 {
4279 panel.update(cx, |panel, cx| {
4280 panel.selected_agent = Agent::NativeAgent;
4281 panel.reset_start_thread_in_to_default(cx);
4282 let id = panel.create_thread(window, cx);
4283 panel.activate_retained_thread(
4284 id, true, window, cx,
4285 );
4286 });
4287 }
4288 });
4289 }
4290 }
4291 }),
4292 )
4293 .map(|mut menu| {
4294 let agent_server_store = agent_server_store.read(cx);
4295 let registry_store = project::AgentRegistryStore::try_global(cx);
4296 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
4297
4298 struct AgentMenuItem {
4299 id: AgentId,
4300 display_name: SharedString,
4301 }
4302
4303 let agent_items = agent_server_store
4304 .external_agents()
4305 .map(|agent_id| {
4306 let display_name = agent_server_store
4307 .agent_display_name(agent_id)
4308 .or_else(|| {
4309 registry_store_ref
4310 .as_ref()
4311 .and_then(|store| store.agent(agent_id))
4312 .map(|a| a.name().clone())
4313 })
4314 .unwrap_or_else(|| agent_id.0.clone());
4315 AgentMenuItem {
4316 id: agent_id.clone(),
4317 display_name,
4318 }
4319 })
4320 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
4321 .collect::<Vec<_>>();
4322
4323 if !agent_items.is_empty() {
4324 menu = menu.separator().header("External Agents");
4325 }
4326 for item in &agent_items {
4327 let mut entry = ContextMenuEntry::new(item.display_name.clone());
4328
4329 let icon_path =
4330 agent_server_store.agent_icon(&item.id).or_else(|| {
4331 registry_store_ref
4332 .as_ref()
4333 .and_then(|store| store.agent(&item.id))
4334 .and_then(|a| a.icon_path().cloned())
4335 });
4336
4337 if let Some(icon_path) = icon_path {
4338 entry = entry.custom_icon_svg(icon_path);
4339 } else {
4340 entry = entry.icon(IconName::Sparkle);
4341 }
4342
4343 entry = entry
4344 .when(
4345 is_agent_selected(Agent::Custom {
4346 id: item.id.clone(),
4347 }),
4348 |this| {
4349 this.action(Box::new(NewExternalAgentThread {
4350 agent: None,
4351 }))
4352 },
4353 )
4354 .icon_color(Color::Muted)
4355 .disabled(is_via_collab)
4356 .handler({
4357 let workspace = workspace.clone();
4358 let agent_id = item.id.clone();
4359 move |window, cx| {
4360 if let Some(workspace) = workspace.upgrade() {
4361 workspace.update(cx, |workspace, cx| {
4362 if let Some(panel) =
4363 workspace.panel::<AgentPanel>(cx)
4364 {
4365 panel.update(cx, |panel, cx| {
4366 panel.selected_agent = Agent::Custom {
4367 id: agent_id.clone(),
4368 };
4369 panel.reset_start_thread_in_to_default(
4370 cx,
4371 );
4372 let id =
4373 panel.create_thread(window, cx);
4374 panel.activate_retained_thread(
4375 id, true, window, cx,
4376 );
4377 });
4378 }
4379 });
4380 }
4381 }
4382 });
4383
4384 menu = menu.item(entry);
4385 }
4386
4387 menu
4388 })
4389 .separator()
4390 .item(
4391 ContextMenuEntry::new("Add More Agents")
4392 .icon(IconName::Plus)
4393 .icon_color(Color::Muted)
4394 .handler({
4395 move |window, cx| {
4396 window
4397 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
4398 }
4399 }),
4400 )
4401 }))
4402 })
4403 };
4404
4405 let is_thread_loading = self
4406 .active_conversation_view()
4407 .map(|thread| thread.read(cx).is_loading())
4408 .unwrap_or(false);
4409
4410 let has_custom_icon = selected_agent_custom_icon.is_some();
4411 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
4412 let selected_agent_builtin_icon = self.selected_agent.icon();
4413 let selected_agent_label_for_tooltip = selected_agent_label.clone();
4414
4415 let selected_agent = div()
4416 .id("selected_agent_icon")
4417 .when_some(selected_agent_custom_icon, |this, icon_path| {
4418 this.px_1().child(
4419 Icon::from_external_svg(icon_path)
4420 .color(Color::Muted)
4421 .size(IconSize::Small),
4422 )
4423 })
4424 .when(!has_custom_icon, |this| {
4425 this.when_some(selected_agent_builtin_icon, |this, icon| {
4426 this.px_1().child(Icon::new(icon).color(Color::Muted))
4427 })
4428 })
4429 .tooltip(move |_, cx| {
4430 Tooltip::with_meta(
4431 selected_agent_label_for_tooltip.clone(),
4432 None,
4433 "Selected Agent",
4434 cx,
4435 )
4436 });
4437
4438 let selected_agent = if is_thread_loading {
4439 selected_agent
4440 .with_animation(
4441 "pulsating-icon",
4442 Animation::new(Duration::from_secs(1))
4443 .repeat()
4444 .with_easing(pulsating_between(0.2, 0.6)),
4445 |icon, delta| icon.opacity(delta),
4446 )
4447 .into_any_element()
4448 } else {
4449 selected_agent.into_any_element()
4450 };
4451
4452 let is_empty_state = !self.active_thread_has_messages(cx);
4453
4454 let is_in_history_or_config = self.is_history_or_configuration_visible();
4455
4456 let is_full_screen = self.is_zoomed(window, cx);
4457 let full_screen_button = if is_full_screen {
4458 IconButton::new("disable-full-screen", IconName::Minimize)
4459 .icon_size(IconSize::Small)
4460 .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
4461 .on_click(cx.listener(move |this, _, window, cx| {
4462 this.toggle_zoom(&ToggleZoom, window, cx);
4463 }))
4464 } else {
4465 IconButton::new("enable-full-screen", IconName::Maximize)
4466 .icon_size(IconSize::Small)
4467 .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
4468 .on_click(cx.listener(move |this, _, window, cx| {
4469 this.toggle_zoom(&ToggleZoom, window, cx);
4470 }))
4471 };
4472
4473 let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
4474
4475 let max_content_width = AgentSettings::get_global(cx).max_content_width;
4476
4477 let base_container = h_flex()
4478 .size_full()
4479 // TODO: This is only until we remove Agent settings from the panel.
4480 .when(!is_in_history_or_config, |this| {
4481 this.max_w(max_content_width).mx_auto()
4482 })
4483 .flex_none()
4484 .justify_between()
4485 .gap_2();
4486
4487 let toolbar_content = if use_v2_empty_toolbar {
4488 let (chevron_icon, icon_color, label_color) =
4489 if self.new_thread_menu_handle.is_deployed() {
4490 (IconName::ChevronUp, Color::Accent, Color::Accent)
4491 } else {
4492 (IconName::ChevronDown, Color::Muted, Color::Default)
4493 };
4494
4495 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
4496 Icon::from_external_svg(icon_path)
4497 .size(IconSize::Small)
4498 .color(icon_color)
4499 } else {
4500 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
4501 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
4502 };
4503
4504 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4505 .start_icon(agent_icon)
4506 .color(label_color)
4507 .end_icon(
4508 Icon::new(chevron_icon)
4509 .color(icon_color)
4510 .size(IconSize::XSmall),
4511 );
4512
4513 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4514 .trigger_with_tooltip(agent_selector_button, {
4515 move |_window, cx| {
4516 Tooltip::for_action_in(
4517 "New Thread…",
4518 &ToggleNewThreadMenu,
4519 &focus_handle,
4520 cx,
4521 )
4522 }
4523 })
4524 .menu({
4525 let builder = new_thread_menu_builder.clone();
4526 move |window, cx| builder(window, cx)
4527 })
4528 .with_handle(self.new_thread_menu_handle.clone())
4529 .anchor(Corner::TopLeft)
4530 .offset(gpui::Point {
4531 x: px(1.0),
4532 y: px(1.0),
4533 });
4534
4535 base_container
4536 .child(
4537 h_flex()
4538 .size_full()
4539 .gap(DynamicSpacing::Base04.rems(cx))
4540 .pl(DynamicSpacing::Base04.rems(cx))
4541 .child(agent_selector_menu)
4542 .when(
4543 has_visible_worktrees && self.project_has_git_repository(cx),
4544 |this| this.child(self.render_start_thread_in_selector(cx)),
4545 )
4546 .when(
4547 matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
4548 |this| this.child(self.render_new_worktree_branch_selector(cx)),
4549 ),
4550 )
4551 .child(
4552 h_flex()
4553 .h_full()
4554 .flex_none()
4555 .gap_1()
4556 .pl_1()
4557 .pr_1()
4558 .child(full_screen_button)
4559 .child(self.render_panel_options_menu(window, cx)),
4560 )
4561 .into_any_element()
4562 } else {
4563 let new_thread_menu = PopoverMenu::new("new_thread_menu")
4564 .trigger_with_tooltip(
4565 IconButton::new("new_thread_menu_btn", IconName::Plus)
4566 .icon_size(IconSize::Small),
4567 {
4568 move |_window, cx| {
4569 Tooltip::for_action_in(
4570 "New Thread\u{2026}",
4571 &ToggleNewThreadMenu,
4572 &focus_handle,
4573 cx,
4574 )
4575 }
4576 },
4577 )
4578 .anchor(Corner::TopRight)
4579 .with_handle(self.new_thread_menu_handle.clone())
4580 .menu(move |window, cx| new_thread_menu_builder(window, cx));
4581
4582 base_container
4583 .child(
4584 h_flex()
4585 .size_full()
4586 .gap(DynamicSpacing::Base04.rems(cx))
4587 .pl(DynamicSpacing::Base04.rems(cx))
4588 .child(if self.is_overlay_open() {
4589 self.render_toolbar_back_button(cx).into_any_element()
4590 } else {
4591 selected_agent.into_any_element()
4592 })
4593 .child(self.render_title_view(window, cx)),
4594 )
4595 .child(
4596 h_flex()
4597 .h_full()
4598 .flex_none()
4599 .gap_1()
4600 .pl_1()
4601 .pr_1()
4602 .child(new_thread_menu)
4603 .child(full_screen_button)
4604 .child(self.render_panel_options_menu(window, cx)),
4605 )
4606 .into_any_element()
4607 };
4608
4609 h_flex()
4610 .id("agent-panel-toolbar")
4611 .h(Tab::container_height(cx))
4612 .flex_shrink_0()
4613 .max_w_full()
4614 .bg(cx.theme().colors().tab_bar_background)
4615 .border_b_1()
4616 .border_color(cx.theme().colors().border)
4617 .child(toolbar_content)
4618 }
4619
4620 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4621 let (view_id, status) = self.worktree_creation_status.as_ref()?;
4622 let active_view_id = self.active_conversation_view().map(|v| v.entity_id());
4623 if active_view_id != Some(*view_id) {
4624 return None;
4625 }
4626 match status {
4627 WorktreeCreationStatus::Creating => Some(
4628 h_flex()
4629 .absolute()
4630 .bottom_12()
4631 .w_full()
4632 .p_2()
4633 .gap_1()
4634 .justify_center()
4635 .bg(cx.theme().colors().editor_background)
4636 .child(
4637 Icon::new(IconName::LoadCircle)
4638 .size(IconSize::Small)
4639 .color(Color::Muted)
4640 .with_rotate_animation(3),
4641 )
4642 .child(
4643 Label::new("Creating Worktree…")
4644 .color(Color::Muted)
4645 .size(LabelSize::Small),
4646 )
4647 .into_any_element(),
4648 ),
4649 WorktreeCreationStatus::Error(message) => Some(
4650 Callout::new()
4651 .icon(IconName::XCircleFilled)
4652 .severity(Severity::Error)
4653 .title("Worktree Creation Error")
4654 .description(message.clone())
4655 .border_position(ui::BorderPosition::Bottom)
4656 .dismiss_action(
4657 IconButton::new("dismiss-worktree-error", IconName::Close)
4658 .icon_size(IconSize::Small)
4659 .tooltip(Tooltip::text("Dismiss"))
4660 .on_click(cx.listener(|this, _, _, cx| {
4661 this.worktree_creation_status = None;
4662 cx.notify();
4663 })),
4664 )
4665 .into_any_element(),
4666 ),
4667 }
4668 }
4669
4670 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4671 if TrialEndUpsell::dismissed(cx) {
4672 return false;
4673 }
4674
4675 match &self.base_view {
4676 BaseView::AgentThread { .. } => {
4677 if LanguageModelRegistry::global(cx)
4678 .read(cx)
4679 .default_model()
4680 .is_some_and(|model| {
4681 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4682 })
4683 {
4684 return false;
4685 }
4686 }
4687 BaseView::Uninitialized => {
4688 return false;
4689 }
4690 }
4691
4692 let plan = self.user_store.read(cx).plan();
4693 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4694
4695 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4696 }
4697
4698 fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
4699 // We only want to show this for existing users: those who
4700 // have used the agent panel before the sidebar was introduced.
4701 // We can infer that state by users having seen the onboarding
4702 // at one point, but not the agent layout onboarding.
4703
4704 let has_messages = self.active_thread_has_messages(cx);
4705 let is_dismissed = self
4706 .agent_layout_onboarding_dismissed
4707 .load(Ordering::Acquire);
4708
4709 if is_dismissed || has_messages {
4710 return false;
4711 }
4712
4713 match &self.base_view {
4714 BaseView::Uninitialized => false,
4715 BaseView::AgentThread { .. } => {
4716 let existing_user = self
4717 .new_user_onboarding_upsell_dismissed
4718 .load(Ordering::Acquire);
4719 existing_user
4720 }
4721 }
4722 }
4723
4724 fn render_agent_layout_onboarding(
4725 &self,
4726 _window: &mut Window,
4727 cx: &mut Context<Self>,
4728 ) -> Option<impl IntoElement> {
4729 if !self.should_render_agent_layout_onboarding(cx) {
4730 return None;
4731 }
4732
4733 Some(div().child(self.agent_layout_onboarding.clone()))
4734 }
4735
4736 fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
4737 self.agent_layout_onboarding_dismissed
4738 .store(true, Ordering::Release);
4739 AgentLayoutOnboarding::set_dismissed(true, cx);
4740 cx.notify();
4741 }
4742
4743 fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
4744 self.new_user_onboarding_upsell_dismissed
4745 .store(true, Ordering::Release);
4746 OnboardingUpsell::set_dismissed(true, cx);
4747 self.dismiss_agent_layout_onboarding(cx);
4748 cx.notify();
4749 }
4750
4751 fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
4752 if self
4753 .new_user_onboarding_upsell_dismissed
4754 .load(Ordering::Acquire)
4755 {
4756 return false;
4757 }
4758
4759 let user_store = self.user_store.read(cx);
4760
4761 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4762 && user_store
4763 .subscription_period()
4764 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4765 .is_some_and(|date| date < chrono::Utc::now())
4766 {
4767 if !self
4768 .new_user_onboarding_upsell_dismissed
4769 .load(Ordering::Acquire)
4770 {
4771 self.dismiss_ai_onboarding(cx);
4772 }
4773 return false;
4774 }
4775
4776 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4777 .visible_providers()
4778 .iter()
4779 .any(|provider| {
4780 provider.is_authenticated(cx)
4781 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4782 });
4783
4784 match &self.base_view {
4785 BaseView::Uninitialized => false,
4786 BaseView::AgentThread { conversation_view }
4787 if conversation_view.read(cx).as_native_thread(cx).is_none() =>
4788 {
4789 false
4790 }
4791 BaseView::AgentThread { conversation_view } => {
4792 let history_is_empty = conversation_view
4793 .read(cx)
4794 .history()
4795 .is_none_or(|h| h.read(cx).is_empty());
4796 history_is_empty || !has_configured_non_zed_providers
4797 }
4798 }
4799 }
4800
4801 fn render_new_user_onboarding(
4802 &mut self,
4803 _window: &mut Window,
4804 cx: &mut Context<Self>,
4805 ) -> Option<impl IntoElement> {
4806 if !self.should_render_new_user_onboarding(cx) {
4807 return None;
4808 }
4809
4810 Some(
4811 div()
4812 .bg(cx.theme().colors().editor_background)
4813 .child(self.new_user_onboarding.clone()),
4814 )
4815 }
4816
4817 fn render_trial_end_upsell(
4818 &self,
4819 _window: &mut Window,
4820 cx: &mut Context<Self>,
4821 ) -> Option<impl IntoElement> {
4822 if !self.should_render_trial_end_upsell(cx) {
4823 return None;
4824 }
4825
4826 Some(
4827 v_flex()
4828 .absolute()
4829 .inset_0()
4830 .size_full()
4831 .bg(cx.theme().colors().panel_background)
4832 .opacity(0.85)
4833 .block_mouse_except_scroll()
4834 .child(EndTrialUpsell::new(Arc::new({
4835 let this = cx.entity();
4836 move |_, cx| {
4837 this.update(cx, |_this, cx| {
4838 TrialEndUpsell::set_dismissed(true, cx);
4839 cx.notify();
4840 });
4841 }
4842 }))),
4843 )
4844 }
4845
4846 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4847 let is_local = self.project.read(cx).is_local();
4848 div()
4849 .invisible()
4850 .absolute()
4851 .top_0()
4852 .right_0()
4853 .bottom_0()
4854 .left_0()
4855 .bg(cx.theme().colors().drop_target_background)
4856 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4857 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4858 .when(is_local, |this| {
4859 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4860 })
4861 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4862 let item = tab.pane.read(cx).item_for_index(tab.ix);
4863 let project_paths = item
4864 .and_then(|item| item.project_path(cx))
4865 .into_iter()
4866 .collect::<Vec<_>>();
4867 this.handle_drop(project_paths, vec![], window, cx);
4868 }))
4869 .on_drop(
4870 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4871 let project_paths = selection
4872 .items()
4873 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4874 .collect::<Vec<_>>();
4875 this.handle_drop(project_paths, vec![], window, cx);
4876 }),
4877 )
4878 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4879 let tasks = paths
4880 .paths()
4881 .iter()
4882 .map(|path| {
4883 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4884 })
4885 .collect::<Vec<_>>();
4886 cx.spawn_in(window, async move |this, cx| {
4887 let mut paths = vec![];
4888 let mut added_worktrees = vec![];
4889 let opened_paths = futures::future::join_all(tasks).await;
4890 for entry in opened_paths {
4891 if let Some((worktree, project_path)) = entry.log_err() {
4892 added_worktrees.push(worktree);
4893 paths.push(project_path);
4894 }
4895 }
4896 this.update_in(cx, |this, window, cx| {
4897 this.handle_drop(paths, added_worktrees, window, cx);
4898 })
4899 .ok();
4900 })
4901 .detach();
4902 }))
4903 }
4904
4905 fn handle_drop(
4906 &mut self,
4907 paths: Vec<ProjectPath>,
4908 added_worktrees: Vec<Entity<Worktree>>,
4909 window: &mut Window,
4910 cx: &mut Context<Self>,
4911 ) {
4912 match &self.base_view {
4913 BaseView::AgentThread { conversation_view } => {
4914 conversation_view.update(cx, |conversation_view, cx| {
4915 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4916 });
4917 }
4918 BaseView::Uninitialized => {}
4919 }
4920 }
4921
4922 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4923 if !self.show_trust_workspace_message {
4924 return None;
4925 }
4926
4927 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4928
4929 Some(
4930 Callout::new()
4931 .icon(IconName::Warning)
4932 .severity(Severity::Warning)
4933 .border_position(ui::BorderPosition::Bottom)
4934 .title("You're in Restricted Mode")
4935 .description(description)
4936 .actions_slot(
4937 Button::new("open-trust-modal", "Configure Project Trust")
4938 .label_size(LabelSize::Small)
4939 .style(ButtonStyle::Outlined)
4940 .on_click({
4941 cx.listener(move |this, _, window, cx| {
4942 this.workspace
4943 .update(cx, |workspace, cx| {
4944 workspace
4945 .show_worktree_trust_security_modal(true, window, cx)
4946 })
4947 .log_err();
4948 })
4949 }),
4950 ),
4951 )
4952 }
4953
4954 fn key_context(&self) -> KeyContext {
4955 let mut key_context = KeyContext::new_with_defaults();
4956 key_context.add("AgentPanel");
4957 match &self.base_view {
4958 BaseView::AgentThread { .. } => key_context.add("acp_thread"),
4959 BaseView::Uninitialized => {}
4960 }
4961 key_context
4962 }
4963}
4964
4965impl Render for AgentPanel {
4966 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4967 // WARNING: Changes to this element hierarchy can have
4968 // non-obvious implications to the layout of children.
4969 //
4970 // If you need to change it, please confirm:
4971 // - The message editor expands (cmd-option-esc) correctly
4972 // - When expanded, the buttons at the bottom of the panel are displayed correctly
4973 // - Font size works as expected and can be changed with cmd-+/cmd-
4974 // - Scrolling in all views works as expected
4975 // - Files can be dropped into the panel
4976 let content = v_flex()
4977 .relative()
4978 .size_full()
4979 .justify_between()
4980 .key_context(self.key_context())
4981 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4982 this.new_thread(action, window, cx);
4983 }))
4984 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4985 this.open_history(window, cx);
4986 }))
4987 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4988 this.open_configuration(window, cx);
4989 }))
4990 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4991 .on_action(cx.listener(Self::deploy_rules_library))
4992 .on_action(cx.listener(Self::go_back))
4993 .on_action(cx.listener(Self::toggle_navigation_menu))
4994 .on_action(cx.listener(Self::toggle_options_menu))
4995 .on_action(cx.listener(Self::increase_font_size))
4996 .on_action(cx.listener(Self::decrease_font_size))
4997 .on_action(cx.listener(Self::reset_font_size))
4998 .on_action(cx.listener(Self::toggle_zoom))
4999 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
5000 if let Some(conversation_view) = this.active_conversation_view() {
5001 conversation_view.update(cx, |conversation_view, cx| {
5002 conversation_view.reauthenticate(window, cx)
5003 })
5004 }
5005 }))
5006 .child(self.render_toolbar(window, cx))
5007 .children(self.render_workspace_trust_message(cx))
5008 .children(self.render_new_user_onboarding(window, cx))
5009 .children(self.render_agent_layout_onboarding(window, cx))
5010 .map(|parent| match self.visible_surface() {
5011 VisibleSurface::Uninitialized => parent,
5012 VisibleSurface::AgentThread(conversation_view) => parent
5013 .child(conversation_view.clone())
5014 .child(self.render_drag_target(cx)),
5015 VisibleSurface::History(view) => parent.child(view.clone()),
5016 VisibleSurface::Configuration(configuration) => {
5017 parent.children(configuration.cloned())
5018 }
5019 })
5020 .children(self.render_worktree_creation_status(cx))
5021 .children(self.render_trial_end_upsell(window, cx));
5022
5023 match self.visible_font_size() {
5024 WhichFontSize::AgentFont => {
5025 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
5026 .size_full()
5027 .child(content)
5028 .into_any()
5029 }
5030 _ => content.into_any(),
5031 }
5032 }
5033}
5034
5035struct PromptLibraryInlineAssist {
5036 workspace: WeakEntity<Workspace>,
5037}
5038
5039impl PromptLibraryInlineAssist {
5040 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
5041 Self { workspace }
5042 }
5043}
5044
5045impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
5046 fn assist(
5047 &self,
5048 prompt_editor: &Entity<Editor>,
5049 initial_prompt: Option<String>,
5050 window: &mut Window,
5051 cx: &mut Context<RulesLibrary>,
5052 ) {
5053 InlineAssistant::update_global(cx, |assistant, cx| {
5054 let Some(workspace) = self.workspace.upgrade() else {
5055 return;
5056 };
5057 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
5058 return;
5059 };
5060 let history = panel
5061 .read(cx)
5062 .connection_store()
5063 .read(cx)
5064 .entry(&crate::Agent::NativeAgent)
5065 .and_then(|s| s.read(cx).history())
5066 .map(|h| h.downgrade());
5067 let project = workspace.read(cx).project().downgrade();
5068 let panel = panel.read(cx);
5069 let thread_store = panel.thread_store().clone();
5070 assistant.assist(
5071 prompt_editor,
5072 self.workspace.clone(),
5073 project,
5074 thread_store,
5075 None,
5076 history,
5077 initial_prompt,
5078 window,
5079 cx,
5080 );
5081 })
5082 }
5083
5084 fn focus_agent_panel(
5085 &self,
5086 workspace: &mut Workspace,
5087 window: &mut Window,
5088 cx: &mut Context<Workspace>,
5089 ) -> bool {
5090 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
5091 }
5092}
5093
5094struct OnboardingUpsell;
5095
5096impl Dismissable for OnboardingUpsell {
5097 const KEY: &'static str = "dismissed-trial-upsell";
5098}
5099
5100struct AgentLayoutOnboarding;
5101
5102impl Dismissable for AgentLayoutOnboarding {
5103 const KEY: &'static str = "dismissed-agent-layout-onboarding";
5104}
5105
5106struct TrialEndUpsell;
5107
5108impl Dismissable for TrialEndUpsell {
5109 const KEY: &'static str = "dismissed-trial-end-upsell";
5110}
5111
5112/// Test-only helper methods
5113#[cfg(any(test, feature = "test-support"))]
5114impl AgentPanel {
5115 pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
5116 Self::new(workspace, None, window, cx)
5117 }
5118
5119 /// Opens an external thread using an arbitrary AgentServer.
5120 ///
5121 /// This is a test-only helper that allows visual tests and integration tests
5122 /// to inject a stub server without modifying production code paths.
5123 /// Not compiled into production builds.
5124 pub fn open_external_thread_with_server(
5125 &mut self,
5126 server: Rc<dyn AgentServer>,
5127 window: &mut Window,
5128 cx: &mut Context<Self>,
5129 ) {
5130 let active_id = self.active_thread_id(cx);
5131 let empty_draft_ids: Vec<ThreadId> = self
5132 .draft_thread_ids(cx)
5133 .into_iter()
5134 .filter(|id| Some(*id) != active_id && self.editor_text(*id, cx).is_none())
5135 .collect();
5136 for id in empty_draft_ids {
5137 self.remove_thread(id, cx);
5138 }
5139
5140 let ext_agent = Agent::Custom {
5141 id: server.agent_id(),
5142 };
5143
5144 let thread = self.create_agent_thread_with_server(
5145 ext_agent,
5146 Some(server),
5147 None,
5148 None,
5149 None,
5150 None,
5151 window,
5152 cx,
5153 );
5154 self.set_base_view(thread.into(), true, window, cx);
5155 }
5156
5157 /// Returns the currently active thread view, if any.
5158 ///
5159 /// This is a test-only accessor that exposes the private `active_thread_view()`
5160 /// method for test assertions. Not compiled into production builds.
5161 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
5162 self.active_conversation_view()
5163 }
5164
5165 /// Sets the start_thread_in value directly, bypassing validation.
5166 ///
5167 /// This is a test-only helper for visual tests that need to show specific
5168 /// start_thread_in states without requiring a real git repository.
5169 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
5170 self.start_thread_in = target;
5171 cx.notify();
5172 }
5173
5174 /// Returns the current worktree creation status.
5175 ///
5176 /// This is a test-only helper for visual tests.
5177 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
5178 self.worktree_creation_status.as_ref().map(|(_, s)| s)
5179 }
5180
5181 /// Sets the worktree creation status directly, associating it with the
5182 /// currently active conversation view.
5183 ///
5184 /// This is a test-only helper for visual tests that need to show the
5185 /// "Creating worktree…" spinner or error banners.
5186 pub fn set_worktree_creation_status_for_tests(
5187 &mut self,
5188 status: Option<WorktreeCreationStatus>,
5189 cx: &mut Context<Self>,
5190 ) {
5191 self.worktree_creation_status = status.map(|s| {
5192 let view_id = self
5193 .active_conversation_view()
5194 .map(|v| v.entity_id())
5195 .unwrap_or_else(|| EntityId::from(0u64));
5196 (view_id, s)
5197 });
5198 cx.notify();
5199 }
5200
5201 /// Opens the history view.
5202 ///
5203 /// This is a test-only helper that exposes the private `open_history()`
5204 /// method for visual tests.
5205 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
5206 self.open_history(window, cx);
5207 }
5208
5209 /// Opens the start_thread_in selector popover menu.
5210 ///
5211 /// This is a test-only helper for visual tests.
5212 pub fn open_start_thread_in_menu_for_tests(
5213 &mut self,
5214 window: &mut Window,
5215 cx: &mut Context<Self>,
5216 ) {
5217 self.start_thread_in_menu_handle.show(window, cx);
5218 }
5219
5220 /// Dismisses the start_thread_in dropdown menu.
5221 ///
5222 /// This is a test-only helper for visual tests.
5223 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
5224 self.start_thread_in_menu_handle.hide(cx);
5225 }
5226}
5227
5228#[cfg(test)]
5229mod tests {
5230 use super::*;
5231 use crate::conversation_view::tests::{StubAgentServer, init_test};
5232 use crate::test_support::{
5233 active_session_id, active_thread_id, open_thread_with_connection,
5234 open_thread_with_custom_connection, send_message,
5235 };
5236 use acp_thread::{StubAgentConnection, ThreadStatus};
5237 use agent_servers::CODEX_ID;
5238 use feature_flags::FeatureFlagAppExt;
5239 use fs::FakeFs;
5240 use gpui::{TestAppContext, VisualTestContext};
5241 use project::Project;
5242 use serde_json::json;
5243 use std::path::Path;
5244 use std::time::Instant;
5245 use workspace::MultiWorkspace;
5246
5247 #[gpui::test]
5248 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
5249 init_test(cx);
5250 cx.update(|cx| {
5251 agent::ThreadStore::init_global(cx);
5252 language_model::LanguageModelRegistry::test(cx);
5253 });
5254
5255 // Create a MultiWorkspace window with two workspaces.
5256 let fs = FakeFs::new(cx.executor());
5257 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
5258 .await;
5259 let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
5260 let project_b = Project::test(fs, [], cx).await;
5261
5262 let multi_workspace =
5263 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5264
5265 let workspace_a = multi_workspace
5266 .read_with(cx, |multi_workspace, _cx| {
5267 multi_workspace.workspace().clone()
5268 })
5269 .unwrap();
5270
5271 let workspace_b = multi_workspace
5272 .update(cx, |multi_workspace, window, cx| {
5273 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
5274 })
5275 .unwrap();
5276
5277 workspace_a.update(cx, |workspace, _cx| {
5278 workspace.set_random_database_id();
5279 });
5280 workspace_b.update(cx, |workspace, _cx| {
5281 workspace.set_random_database_id();
5282 });
5283
5284 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5285
5286 // Set up workspace A: with an active thread.
5287 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
5288 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5289 });
5290
5291 panel_a.update_in(cx, |panel, window, cx| {
5292 panel.open_external_thread_with_server(
5293 Rc::new(StubAgentServer::default_response()),
5294 window,
5295 cx,
5296 );
5297 });
5298
5299 cx.run_until_parked();
5300
5301 panel_a.read_with(cx, |panel, cx| {
5302 assert!(
5303 panel.active_agent_thread(cx).is_some(),
5304 "workspace A should have an active thread after connection"
5305 );
5306 });
5307
5308 send_message(&panel_a, cx);
5309
5310 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
5311
5312 // Set up workspace B: ClaudeCode, no active thread.
5313 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
5314 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5315 });
5316
5317 panel_b.update(cx, |panel, _cx| {
5318 panel.selected_agent = Agent::Custom {
5319 id: "claude-acp".into(),
5320 };
5321 });
5322
5323 // Serialize both panels.
5324 panel_a.update(cx, |panel, cx| panel.serialize(cx));
5325 panel_b.update(cx, |panel, cx| panel.serialize(cx));
5326 cx.run_until_parked();
5327
5328 // Load fresh panels for each workspace and verify independent state.
5329 let async_cx = cx.update(|window, cx| window.to_async(cx));
5330 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
5331 .await
5332 .expect("panel A load should succeed");
5333 cx.run_until_parked();
5334
5335 let async_cx = cx.update(|window, cx| window.to_async(cx));
5336 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
5337 .await
5338 .expect("panel B load should succeed");
5339 cx.run_until_parked();
5340
5341 // Workspace A should restore its thread and agent type
5342 loaded_a.read_with(cx, |panel, _cx| {
5343 assert_eq!(
5344 panel.selected_agent, agent_type_a,
5345 "workspace A agent type should be restored"
5346 );
5347 assert!(
5348 panel.active_conversation_view().is_some(),
5349 "workspace A should have its active thread restored"
5350 );
5351 });
5352
5353 // Workspace B should restore its own agent type, with no thread
5354 loaded_b.read_with(cx, |panel, _cx| {
5355 assert_eq!(
5356 panel.selected_agent,
5357 Agent::Custom {
5358 id: "claude-acp".into()
5359 },
5360 "workspace B agent type should be restored"
5361 );
5362 assert!(
5363 panel.active_conversation_view().is_none(),
5364 "workspace B should have no active thread"
5365 );
5366 });
5367 }
5368
5369 #[gpui::test]
5370 async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
5371 init_test(cx);
5372 cx.update(|cx| {
5373 agent::ThreadStore::init_global(cx);
5374 language_model::LanguageModelRegistry::test(cx);
5375 });
5376
5377 let fs = FakeFs::new(cx.executor());
5378 let project = Project::test(fs, [], cx).await;
5379
5380 let multi_workspace =
5381 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5382
5383 let workspace = multi_workspace
5384 .read_with(cx, |multi_workspace, _cx| {
5385 multi_workspace.workspace().clone()
5386 })
5387 .unwrap();
5388
5389 workspace.update(cx, |workspace, _cx| {
5390 workspace.set_random_database_id();
5391 });
5392
5393 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5394
5395 let panel = workspace.update_in(cx, |workspace, window, cx| {
5396 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5397 });
5398
5399 panel.update_in(cx, |panel, window, cx| {
5400 panel.open_external_thread_with_server(
5401 Rc::new(StubAgentServer::default_response()),
5402 window,
5403 cx,
5404 );
5405 });
5406
5407 cx.run_until_parked();
5408
5409 panel.read_with(cx, |panel, cx| {
5410 assert!(
5411 panel.active_agent_thread(cx).is_some(),
5412 "should have an active thread after connection"
5413 );
5414 });
5415
5416 // Serialize without ever sending a message, so no thread metadata exists.
5417 panel.update(cx, |panel, cx| panel.serialize(cx));
5418 cx.run_until_parked();
5419
5420 let async_cx = cx.update(|window, cx| window.to_async(cx));
5421 let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
5422 .await
5423 .expect("panel load should succeed");
5424 cx.run_until_parked();
5425
5426 loaded.read_with(cx, |panel, _cx| {
5427 assert!(
5428 panel.active_conversation_view().is_none(),
5429 "thread without metadata should not be restored"
5430 );
5431 });
5432 }
5433
5434 /// Extracts the text from a Text content block, panicking if it's not Text.
5435 fn expect_text_block(block: &acp::ContentBlock) -> &str {
5436 match block {
5437 acp::ContentBlock::Text(t) => t.text.as_str(),
5438 other => panic!("expected Text block, got {:?}", other),
5439 }
5440 }
5441
5442 /// Extracts the (text_content, uri) from a Resource content block, panicking
5443 /// if it's not a TextResourceContents resource.
5444 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
5445 match block {
5446 acp::ContentBlock::Resource(r) => match &r.resource {
5447 acp::EmbeddedResourceResource::TextResourceContents(t) => {
5448 (t.text.as_str(), t.uri.as_str())
5449 }
5450 other => panic!("expected TextResourceContents, got {:?}", other),
5451 },
5452 other => panic!("expected Resource block, got {:?}", other),
5453 }
5454 }
5455
5456 #[test]
5457 fn test_build_conflict_resolution_prompt_single_conflict() {
5458 let conflicts = vec![ConflictContent {
5459 file_path: "src/main.rs".to_string(),
5460 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
5461 .to_string(),
5462 ours_branch_name: "HEAD".to_string(),
5463 theirs_branch_name: "feature".to_string(),
5464 }];
5465
5466 let blocks = build_conflict_resolution_prompt(&conflicts);
5467 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
5468 assert_eq!(
5469 blocks.len(),
5470 4,
5471 "expected 2 text + 1 resource link + 1 resource block"
5472 );
5473
5474 let intro_text = expect_text_block(&blocks[0]);
5475 assert!(
5476 intro_text.contains("Please resolve the following merge conflict in"),
5477 "prompt should include single-conflict intro text"
5478 );
5479
5480 match &blocks[1] {
5481 acp::ContentBlock::ResourceLink(link) => {
5482 assert!(
5483 link.uri.contains("file://"),
5484 "resource link URI should use file scheme"
5485 );
5486 assert!(
5487 link.uri.contains("main.rs"),
5488 "resource link URI should reference file path"
5489 );
5490 }
5491 other => panic!("expected ResourceLink block, got {:?}", other),
5492 }
5493
5494 let body_text = expect_text_block(&blocks[2]);
5495 assert!(
5496 body_text.contains("`HEAD` (ours)"),
5497 "prompt should mention ours branch"
5498 );
5499 assert!(
5500 body_text.contains("`feature` (theirs)"),
5501 "prompt should mention theirs branch"
5502 );
5503 assert!(
5504 body_text.contains("editing the file directly"),
5505 "prompt should instruct the agent to edit the file"
5506 );
5507
5508 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
5509 assert!(
5510 resource_text.contains("<<<<<<< HEAD"),
5511 "resource should contain the conflict text"
5512 );
5513 assert!(
5514 resource_uri.contains("merge-conflict"),
5515 "resource URI should use the merge-conflict scheme"
5516 );
5517 assert!(
5518 resource_uri.contains("main.rs"),
5519 "resource URI should reference the file path"
5520 );
5521 }
5522
5523 #[test]
5524 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
5525 let conflicts = vec![
5526 ConflictContent {
5527 file_path: "src/lib.rs".to_string(),
5528 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
5529 .to_string(),
5530 ours_branch_name: "main".to_string(),
5531 theirs_branch_name: "dev".to_string(),
5532 },
5533 ConflictContent {
5534 file_path: "src/lib.rs".to_string(),
5535 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5536 .to_string(),
5537 ours_branch_name: "main".to_string(),
5538 theirs_branch_name: "dev".to_string(),
5539 },
5540 ];
5541
5542 let blocks = build_conflict_resolution_prompt(&conflicts);
5543 // 1 Text instruction + 2 Resource blocks
5544 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5545
5546 let text = expect_text_block(&blocks[0]);
5547 assert!(
5548 text.contains("all 2 merge conflicts"),
5549 "prompt should mention the total count"
5550 );
5551 assert!(
5552 text.contains("`main` (ours)"),
5553 "prompt should mention ours branch"
5554 );
5555 assert!(
5556 text.contains("`dev` (theirs)"),
5557 "prompt should mention theirs branch"
5558 );
5559 // Single file, so "file" not "files"
5560 assert!(
5561 text.contains("file directly"),
5562 "single file should use singular 'file'"
5563 );
5564
5565 let (resource_a, _) = expect_resource_block(&blocks[1]);
5566 let (resource_b, _) = expect_resource_block(&blocks[2]);
5567 assert!(
5568 resource_a.contains("fn a()"),
5569 "first resource should contain first conflict"
5570 );
5571 assert!(
5572 resource_b.contains("fn b()"),
5573 "second resource should contain second conflict"
5574 );
5575 }
5576
5577 #[test]
5578 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5579 let conflicts = vec![
5580 ConflictContent {
5581 file_path: "src/a.rs".to_string(),
5582 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5583 ours_branch_name: "main".to_string(),
5584 theirs_branch_name: "dev".to_string(),
5585 },
5586 ConflictContent {
5587 file_path: "src/b.rs".to_string(),
5588 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5589 ours_branch_name: "main".to_string(),
5590 theirs_branch_name: "dev".to_string(),
5591 },
5592 ];
5593
5594 let blocks = build_conflict_resolution_prompt(&conflicts);
5595 // 1 Text instruction + 2 Resource blocks
5596 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5597
5598 let text = expect_text_block(&blocks[0]);
5599 assert!(
5600 text.contains("files directly"),
5601 "multiple files should use plural 'files'"
5602 );
5603
5604 let (_, uri_a) = expect_resource_block(&blocks[1]);
5605 let (_, uri_b) = expect_resource_block(&blocks[2]);
5606 assert!(
5607 uri_a.contains("a.rs"),
5608 "first resource URI should reference a.rs"
5609 );
5610 assert!(
5611 uri_b.contains("b.rs"),
5612 "second resource URI should reference b.rs"
5613 );
5614 }
5615
5616 #[test]
5617 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5618 let file_paths = vec![
5619 "src/main.rs".to_string(),
5620 "src/lib.rs".to_string(),
5621 "tests/integration.rs".to_string(),
5622 ];
5623
5624 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5625 // 1 instruction Text block + (ResourceLink + newline Text) per file
5626 assert_eq!(
5627 blocks.len(),
5628 1 + (file_paths.len() * 2),
5629 "expected instruction text plus resource links and separators"
5630 );
5631
5632 let text = expect_text_block(&blocks[0]);
5633 assert!(
5634 text.contains("unresolved merge conflicts"),
5635 "prompt should describe the task"
5636 );
5637 assert!(
5638 text.contains("conflict markers"),
5639 "prompt should mention conflict markers"
5640 );
5641
5642 for (index, path) in file_paths.iter().enumerate() {
5643 let link_index = 1 + (index * 2);
5644 let newline_index = link_index + 1;
5645
5646 match &blocks[link_index] {
5647 acp::ContentBlock::ResourceLink(link) => {
5648 assert!(
5649 link.uri.contains("file://"),
5650 "resource link URI should use file scheme"
5651 );
5652 assert!(
5653 link.uri.contains(path),
5654 "resource link URI should reference file path: {path}"
5655 );
5656 }
5657 other => panic!(
5658 "expected ResourceLink block at index {}, got {:?}",
5659 link_index, other
5660 ),
5661 }
5662
5663 let separator = expect_text_block(&blocks[newline_index]);
5664 assert_eq!(
5665 separator, "\n",
5666 "expected newline separator after each file"
5667 );
5668 }
5669 }
5670
5671 #[test]
5672 fn test_build_conflict_resolution_prompt_empty_conflicts() {
5673 let blocks = build_conflict_resolution_prompt(&[]);
5674 assert!(
5675 blocks.is_empty(),
5676 "empty conflicts should produce no blocks, got {} blocks",
5677 blocks.len()
5678 );
5679 }
5680
5681 #[test]
5682 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5683 let blocks = build_conflicted_files_resolution_prompt(&[]);
5684 assert!(
5685 blocks.is_empty(),
5686 "empty paths should produce no blocks, got {} blocks",
5687 blocks.len()
5688 );
5689 }
5690
5691 #[test]
5692 fn test_conflict_resource_block_structure() {
5693 let conflict = ConflictContent {
5694 file_path: "src/utils.rs".to_string(),
5695 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5696 ours_branch_name: "HEAD".to_string(),
5697 theirs_branch_name: "branch".to_string(),
5698 };
5699
5700 let block = conflict_resource_block(&conflict);
5701 let (text, uri) = expect_resource_block(&block);
5702
5703 assert_eq!(
5704 text, conflict.conflict_text,
5705 "resource text should be the raw conflict"
5706 );
5707 assert!(
5708 uri.starts_with("zed:///agent/merge-conflict"),
5709 "URI should use the zed merge-conflict scheme, got: {uri}"
5710 );
5711 assert!(uri.contains("utils.rs"), "URI should encode the file path");
5712 }
5713
5714 fn open_generating_thread_with_loadable_connection(
5715 panel: &Entity<AgentPanel>,
5716 connection: &StubAgentConnection,
5717 cx: &mut VisualTestContext,
5718 ) -> (acp::SessionId, ThreadId) {
5719 open_thread_with_custom_connection(panel, connection.clone(), cx);
5720 let session_id = active_session_id(panel, cx);
5721 let thread_id = active_thread_id(panel, cx);
5722 send_message(panel, cx);
5723 cx.update(|_, cx| {
5724 connection.send_update(
5725 session_id.clone(),
5726 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5727 cx,
5728 );
5729 });
5730 cx.run_until_parked();
5731 (session_id, thread_id)
5732 }
5733
5734 fn open_idle_thread_with_non_loadable_connection(
5735 panel: &Entity<AgentPanel>,
5736 connection: &StubAgentConnection,
5737 cx: &mut VisualTestContext,
5738 ) -> (acp::SessionId, ThreadId) {
5739 open_thread_with_custom_connection(panel, connection.clone(), cx);
5740 let session_id = active_session_id(panel, cx);
5741 let thread_id = active_thread_id(panel, cx);
5742
5743 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5744 acp::ContentChunk::new("done".into()),
5745 )]);
5746 send_message(panel, cx);
5747
5748 (session_id, thread_id)
5749 }
5750
5751 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5752 init_test(cx);
5753 cx.update(|cx| {
5754 agent::ThreadStore::init_global(cx);
5755 language_model::LanguageModelRegistry::test(cx);
5756 });
5757
5758 let fs = FakeFs::new(cx.executor());
5759 let project = Project::test(fs.clone(), [], cx).await;
5760
5761 let multi_workspace =
5762 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5763
5764 let workspace = multi_workspace
5765 .read_with(cx, |mw, _cx| mw.workspace().clone())
5766 .unwrap();
5767
5768 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5769
5770 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5771 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5772 });
5773
5774 (panel, cx)
5775 }
5776
5777 #[gpui::test]
5778 async fn test_empty_draft_discarded_when_navigating_away(cx: &mut TestAppContext) {
5779 let (panel, mut cx) = setup_panel(cx).await;
5780
5781 let connection_a = StubAgentConnection::new();
5782 open_thread_with_connection(&panel, connection_a, &mut cx);
5783
5784 panel.read_with(&cx, |panel, cx| {
5785 let thread = panel.active_agent_thread(cx).unwrap();
5786 assert!(
5787 thread.read(cx).entries().is_empty(),
5788 "newly opened draft thread should have no entries"
5789 );
5790 assert!(panel.retained_threads.is_empty());
5791 });
5792
5793 let connection_b = StubAgentConnection::new();
5794 open_thread_with_connection(&panel, connection_b, &mut cx);
5795
5796 panel.read_with(&cx, |panel, _cx| {
5797 assert!(
5798 panel.retained_threads.is_empty(),
5799 "empty draft should be discarded, not retained"
5800 );
5801 });
5802 }
5803
5804 #[gpui::test]
5805 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5806 let (panel, mut cx) = setup_panel(cx).await;
5807
5808 let connection_a = StubAgentConnection::new();
5809 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5810 send_message(&panel, &mut cx);
5811
5812 let session_id_a = active_session_id(&panel, &cx);
5813 let thread_id_a = active_thread_id(&panel, &cx);
5814
5815 // Send a chunk to keep thread A generating (don't end the turn).
5816 cx.update(|_, cx| {
5817 connection_a.send_update(
5818 session_id_a.clone(),
5819 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5820 cx,
5821 );
5822 });
5823 cx.run_until_parked();
5824
5825 // Verify thread A is generating.
5826 panel.read_with(&cx, |panel, cx| {
5827 let thread = panel.active_agent_thread(cx).unwrap();
5828 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5829 assert!(panel.retained_threads.is_empty());
5830 });
5831
5832 // Open a new thread B — thread A should be retained in background.
5833 let connection_b = StubAgentConnection::new();
5834 open_thread_with_connection(&panel, connection_b, &mut cx);
5835
5836 panel.read_with(&cx, |panel, _cx| {
5837 assert_eq!(
5838 panel.retained_threads.len(),
5839 1,
5840 "Running thread A should be retained in retained_threads"
5841 );
5842 assert!(
5843 panel.retained_threads.contains_key(&thread_id_a),
5844 "Retained thread should be keyed by thread A's thread ID"
5845 );
5846 });
5847 }
5848
5849 #[gpui::test]
5850 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5851 let (panel, mut cx) = setup_panel(cx).await;
5852
5853 let connection_a = StubAgentConnection::new();
5854 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5855 acp::ContentChunk::new("Response".into()),
5856 )]);
5857 open_thread_with_connection(&panel, connection_a, &mut cx);
5858 send_message(&panel, &mut cx);
5859
5860 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5861 panel.active_conversation_view().unwrap().downgrade()
5862 });
5863 let thread_id_a = active_thread_id(&panel, &cx);
5864
5865 // Thread A should be idle (auto-completed via set_next_prompt_updates).
5866 panel.read_with(&cx, |panel, cx| {
5867 let thread = panel.active_agent_thread(cx).unwrap();
5868 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5869 });
5870
5871 // Open a new thread B — thread A should be retained because it is not loadable.
5872 let connection_b = StubAgentConnection::new();
5873 open_thread_with_connection(&panel, connection_b, &mut cx);
5874
5875 panel.read_with(&cx, |panel, _cx| {
5876 assert_eq!(
5877 panel.retained_threads.len(),
5878 1,
5879 "Idle non-loadable thread A should be retained in retained_threads"
5880 );
5881 assert!(
5882 panel.retained_threads.contains_key(&thread_id_a),
5883 "Retained thread should be keyed by thread A's thread ID"
5884 );
5885 });
5886
5887 assert!(
5888 weak_view_a.upgrade().is_some(),
5889 "Idle non-loadable ConnectionView should still be retained"
5890 );
5891 }
5892
5893 #[gpui::test]
5894 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5895 let (panel, mut cx) = setup_panel(cx).await;
5896
5897 let connection_a = StubAgentConnection::new();
5898 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5899 send_message(&panel, &mut cx);
5900
5901 let session_id_a = active_session_id(&panel, &cx);
5902 let thread_id_a = active_thread_id(&panel, &cx);
5903
5904 // Keep thread A generating.
5905 cx.update(|_, cx| {
5906 connection_a.send_update(
5907 session_id_a.clone(),
5908 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5909 cx,
5910 );
5911 });
5912 cx.run_until_parked();
5913
5914 // Open thread B — thread A goes to background.
5915 let connection_b = StubAgentConnection::new();
5916 open_thread_with_connection(&panel, connection_b, &mut cx);
5917 send_message(&panel, &mut cx);
5918
5919 let thread_id_b = active_thread_id(&panel, &cx);
5920
5921 panel.read_with(&cx, |panel, _cx| {
5922 assert_eq!(panel.retained_threads.len(), 1);
5923 assert!(panel.retained_threads.contains_key(&thread_id_a));
5924 });
5925
5926 // Load thread A back via load_agent_thread — should promote from background.
5927 panel.update_in(&mut cx, |panel, window, cx| {
5928 panel.load_agent_thread(
5929 panel.selected_agent().expect("selected agent must be set"),
5930 session_id_a.clone(),
5931 None,
5932 None,
5933 true,
5934 window,
5935 cx,
5936 );
5937 });
5938
5939 // Thread A should now be the active view, promoted from background.
5940 let active_session = active_session_id(&panel, &cx);
5941 assert_eq!(
5942 active_session, session_id_a,
5943 "Thread A should be the active thread after promotion"
5944 );
5945
5946 panel.read_with(&cx, |panel, _cx| {
5947 assert!(
5948 !panel.retained_threads.contains_key(&thread_id_a),
5949 "Promoted thread A should no longer be in retained_threads"
5950 );
5951 assert!(
5952 panel.retained_threads.contains_key(&thread_id_b),
5953 "Thread B (idle, non-loadable) should remain retained in retained_threads"
5954 );
5955 });
5956 }
5957
5958 #[gpui::test]
5959 async fn test_cleanup_retained_threads_keeps_five_most_recent_idle_loadable_threads(
5960 cx: &mut TestAppContext,
5961 ) {
5962 let (panel, mut cx) = setup_panel(cx).await;
5963 let connection = StubAgentConnection::new()
5964 .with_supports_load_session(true)
5965 .with_agent_id("loadable-stub".into())
5966 .with_telemetry_id("loadable-stub".into());
5967 let mut session_ids = Vec::new();
5968 let mut thread_ids = Vec::new();
5969
5970 for _ in 0..7 {
5971 let (session_id, thread_id) =
5972 open_generating_thread_with_loadable_connection(&panel, &connection, &mut cx);
5973 session_ids.push(session_id);
5974 thread_ids.push(thread_id);
5975 }
5976
5977 let base_time = Instant::now();
5978
5979 for session_id in session_ids.iter().take(6) {
5980 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5981 }
5982 cx.run_until_parked();
5983
5984 panel.update(&mut cx, |panel, cx| {
5985 for (index, thread_id) in thread_ids.iter().take(6).enumerate() {
5986 let conversation_view = panel
5987 .retained_threads
5988 .get(thread_id)
5989 .expect("retained thread should exist")
5990 .clone();
5991 conversation_view.update(cx, |view, cx| {
5992 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5993 });
5994 }
5995 panel.cleanup_retained_threads(cx);
5996 });
5997
5998 panel.read_with(&cx, |panel, _cx| {
5999 assert_eq!(
6000 panel.retained_threads.len(),
6001 5,
6002 "cleanup should keep at most five idle loadable retained threads"
6003 );
6004 assert!(
6005 !panel.retained_threads.contains_key(&thread_ids[0]),
6006 "oldest idle loadable retained thread should be removed"
6007 );
6008 for thread_id in &thread_ids[1..6] {
6009 assert!(
6010 panel.retained_threads.contains_key(thread_id),
6011 "more recent idle loadable retained threads should be retained"
6012 );
6013 }
6014 assert!(
6015 !panel.retained_threads.contains_key(&thread_ids[6]),
6016 "the active thread should not also be stored as a retained thread"
6017 );
6018 });
6019 }
6020
6021 #[gpui::test]
6022 async fn test_cleanup_retained_threads_preserves_idle_non_loadable_threads(
6023 cx: &mut TestAppContext,
6024 ) {
6025 let (panel, mut cx) = setup_panel(cx).await;
6026
6027 let non_loadable_connection = StubAgentConnection::new();
6028 let (_non_loadable_session_id, non_loadable_thread_id) =
6029 open_idle_thread_with_non_loadable_connection(
6030 &panel,
6031 &non_loadable_connection,
6032 &mut cx,
6033 );
6034
6035 let loadable_connection = StubAgentConnection::new()
6036 .with_supports_load_session(true)
6037 .with_agent_id("loadable-stub".into())
6038 .with_telemetry_id("loadable-stub".into());
6039 let mut loadable_session_ids = Vec::new();
6040 let mut loadable_thread_ids = Vec::new();
6041
6042 for _ in 0..7 {
6043 let (session_id, thread_id) = open_generating_thread_with_loadable_connection(
6044 &panel,
6045 &loadable_connection,
6046 &mut cx,
6047 );
6048 loadable_session_ids.push(session_id);
6049 loadable_thread_ids.push(thread_id);
6050 }
6051
6052 let base_time = Instant::now();
6053
6054 for session_id in loadable_session_ids.iter().take(6) {
6055 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
6056 }
6057 cx.run_until_parked();
6058
6059 panel.update(&mut cx, |panel, cx| {
6060 for (index, thread_id) in loadable_thread_ids.iter().take(6).enumerate() {
6061 let conversation_view = panel
6062 .retained_threads
6063 .get(thread_id)
6064 .expect("retained thread should exist")
6065 .clone();
6066 conversation_view.update(cx, |view, cx| {
6067 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
6068 });
6069 }
6070 panel.cleanup_retained_threads(cx);
6071 });
6072
6073 panel.read_with(&cx, |panel, _cx| {
6074 assert_eq!(
6075 panel.retained_threads.len(),
6076 6,
6077 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
6078 );
6079 assert!(
6080 panel.retained_threads.contains_key(&non_loadable_thread_id),
6081 "idle non-loadable retained threads should not be cleanup candidates"
6082 );
6083 assert!(
6084 !panel.retained_threads.contains_key(&loadable_thread_ids[0]),
6085 "oldest idle loadable retained thread should still be removed"
6086 );
6087 for thread_id in &loadable_thread_ids[1..6] {
6088 assert!(
6089 panel.retained_threads.contains_key(thread_id),
6090 "more recent idle loadable retained threads should be retained"
6091 );
6092 }
6093 assert!(
6094 !panel.retained_threads.contains_key(&loadable_thread_ids[6]),
6095 "the active loadable thread should not also be stored as a retained thread"
6096 );
6097 });
6098 }
6099
6100 #[gpui::test]
6101 async fn test_clear_active_thread_creates_real_empty_draft(cx: &mut TestAppContext) {
6102 let (panel, mut cx) = setup_panel(cx).await;
6103
6104 let connection = StubAgentConnection::new();
6105 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6106 acp::ContentChunk::new("done".into()),
6107 )]);
6108 open_thread_with_connection(&panel, connection, &mut cx);
6109 send_message(&panel, &mut cx);
6110
6111 panel.read_with(&cx, |panel, cx| {
6112 assert!(
6113 panel.draft_thread_ids(cx).is_empty(),
6114 "sent thread should not leave any draft entries before clearing"
6115 );
6116 });
6117
6118 panel.update_in(&mut cx, |panel, window, cx| {
6119 panel.clear_active_thread(window, cx);
6120 });
6121 cx.run_until_parked();
6122
6123 panel.read_with(&cx, |panel, cx| {
6124 assert!(panel.active_thread_is_draft(cx));
6125 assert_eq!(panel.draft_thread_ids(cx).len(), 1);
6126 });
6127 }
6128
6129 #[gpui::test]
6130 async fn test_clear_active_thread_reuses_retained_empty_draft(cx: &mut TestAppContext) {
6131 let (panel, mut cx) = setup_panel(cx).await;
6132
6133 let connection_a = StubAgentConnection::new();
6134 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6135 acp::ContentChunk::new("done".into()),
6136 )]);
6137 open_thread_with_connection(&panel, connection_a, &mut cx);
6138 send_message(&panel, &mut cx);
6139
6140 panel.update_in(&mut cx, |panel, window, cx| {
6141 panel.new_thread(&NewThread, window, cx);
6142 });
6143 cx.run_until_parked();
6144
6145 let retained_draft_id = panel.read_with(&cx, |panel, cx| {
6146 let ids = panel.draft_thread_ids(cx);
6147 assert_eq!(ids.len(), 1);
6148 ids[0]
6149 });
6150
6151 panel.update_in(&mut cx, |panel, window, cx| {
6152 panel.clear_active_thread(window, cx);
6153 });
6154 cx.run_until_parked();
6155
6156 panel.read_with(&cx, |panel, cx| {
6157 assert_eq!(panel.active_thread_id(cx), Some(retained_draft_id));
6158 assert!(panel.active_thread_is_draft(cx));
6159 assert_eq!(panel.draft_thread_ids(cx), vec![retained_draft_id]);
6160 });
6161 }
6162
6163 #[gpui::test]
6164 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
6165 init_test(cx);
6166 cx.update(|cx| {
6167 agent::ThreadStore::init_global(cx);
6168 language_model::LanguageModelRegistry::test(cx);
6169 });
6170
6171 let fs = FakeFs::new(cx.executor());
6172 fs.insert_tree(
6173 "/project",
6174 json!({
6175 ".git": {},
6176 "src": {
6177 "main.rs": "fn main() {}"
6178 }
6179 }),
6180 )
6181 .await;
6182 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6183
6184 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6185
6186 let multi_workspace =
6187 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6188
6189 let workspace = multi_workspace
6190 .read_with(cx, |multi_workspace, _cx| {
6191 multi_workspace.workspace().clone()
6192 })
6193 .unwrap();
6194
6195 workspace.update(cx, |workspace, _cx| {
6196 workspace.set_random_database_id();
6197 });
6198
6199 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6200
6201 // Wait for the project to discover the git repository.
6202 cx.run_until_parked();
6203
6204 let panel = workspace.update_in(cx, |workspace, window, cx| {
6205 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6206 workspace.add_panel(panel.clone(), window, cx);
6207 panel
6208 });
6209
6210 cx.run_until_parked();
6211
6212 // Default thread target should be LocalProject.
6213 panel.read_with(cx, |panel, _cx| {
6214 assert_eq!(
6215 *panel.start_thread_in(),
6216 StartThreadIn::LocalProject,
6217 "default thread target should be LocalProject"
6218 );
6219 });
6220
6221 // Start a new thread with the default LocalProject target.
6222 // Use StubAgentServer so the thread connects immediately in tests.
6223 panel.update_in(cx, |panel, window, cx| {
6224 panel.open_external_thread_with_server(
6225 Rc::new(StubAgentServer::default_response()),
6226 window,
6227 cx,
6228 );
6229 });
6230
6231 cx.run_until_parked();
6232
6233 // MultiWorkspace should still have exactly one workspace (no worktree created).
6234 multi_workspace
6235 .read_with(cx, |multi_workspace, _cx| {
6236 assert_eq!(
6237 multi_workspace.workspaces().count(),
6238 1,
6239 "LocalProject should not create a new workspace"
6240 );
6241 })
6242 .unwrap();
6243
6244 // The thread should be active in the panel.
6245 panel.read_with(cx, |panel, cx| {
6246 assert!(
6247 panel.active_agent_thread(cx).is_some(),
6248 "a thread should be running in the current workspace"
6249 );
6250 });
6251
6252 // The thread target should still be LocalProject (unchanged).
6253 panel.read_with(cx, |panel, _cx| {
6254 assert_eq!(
6255 *panel.start_thread_in(),
6256 StartThreadIn::LocalProject,
6257 "thread target should remain LocalProject"
6258 );
6259 });
6260
6261 // No worktree creation status should be set.
6262 panel.read_with(cx, |panel, _cx| {
6263 assert!(
6264 panel.worktree_creation_status.is_none(),
6265 "no worktree creation should have occurred"
6266 );
6267 });
6268 }
6269
6270 #[gpui::test]
6271 async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target(
6272 cx: &mut TestAppContext,
6273 ) {
6274 use git::repository::Worktree as GitWorktree;
6275
6276 init_test(cx);
6277 cx.update(|cx| {
6278 agent::ThreadStore::init_global(cx);
6279 language_model::LanguageModelRegistry::test(cx);
6280 });
6281
6282 let fs = FakeFs::new(cx.executor());
6283 fs.insert_tree(
6284 "/project",
6285 json!({
6286 ".git": {},
6287 "src": {
6288 "main.rs": "fn main() {}"
6289 }
6290 }),
6291 )
6292 .await;
6293 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6294 fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]);
6295
6296 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6297
6298 let multi_workspace =
6299 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6300
6301 let workspace = multi_workspace
6302 .read_with(cx, |multi_workspace, _cx| {
6303 multi_workspace.workspace().clone()
6304 })
6305 .unwrap();
6306
6307 workspace.update(cx, |workspace, _cx| {
6308 workspace.set_random_database_id();
6309 });
6310
6311 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6312
6313 cx.run_until_parked();
6314
6315 let panel = workspace.update_in(cx, |workspace, window, cx| {
6316 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6317 workspace.add_panel(panel.clone(), window, cx);
6318 panel
6319 });
6320
6321 cx.run_until_parked();
6322
6323 panel.update_in(cx, |panel, window, cx| {
6324 panel.set_start_thread_in(
6325 &StartThreadIn::NewWorktree {
6326 worktree_name: Some("feature worktree".to_string()),
6327 branch_target: NewWorktreeBranchTarget::CurrentBranch,
6328 },
6329 window,
6330 cx,
6331 );
6332 });
6333
6334 fs.add_linked_worktree_for_repo(
6335 Path::new("/project/.git"),
6336 true,
6337 GitWorktree {
6338 path: PathBuf::from("/linked-feature-worktree"),
6339 ref_name: Some("refs/heads/feature-worktree".into()),
6340 sha: "abcdef1".into(),
6341 is_main: false,
6342 },
6343 )
6344 .await;
6345
6346 project
6347 .update(cx, |project, cx| project.git_scans_complete(cx))
6348 .await;
6349 cx.run_until_parked();
6350
6351 panel.read_with(cx, |panel, _cx| {
6352 assert_eq!(
6353 *panel.start_thread_in(),
6354 StartThreadIn::NewWorktree {
6355 worktree_name: Some("feature worktree".to_string()),
6356 branch_target: NewWorktreeBranchTarget::CurrentBranch,
6357 },
6358 "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target",
6359 );
6360 });
6361 }
6362
6363 #[gpui::test]
6364 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
6365 init_test(cx);
6366 cx.update(|cx| {
6367 agent::ThreadStore::init_global(cx);
6368 language_model::LanguageModelRegistry::test(cx);
6369 });
6370
6371 let fs = FakeFs::new(cx.executor());
6372 fs.insert_tree(
6373 "/project",
6374 json!({
6375 ".git": {},
6376 "src": {
6377 "main.rs": "fn main() {}"
6378 }
6379 }),
6380 )
6381 .await;
6382 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6383
6384 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6385
6386 let multi_workspace =
6387 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6388
6389 let workspace = multi_workspace
6390 .read_with(cx, |multi_workspace, _cx| {
6391 multi_workspace.workspace().clone()
6392 })
6393 .unwrap();
6394
6395 workspace.update(cx, |workspace, _cx| {
6396 workspace.set_random_database_id();
6397 });
6398
6399 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6400
6401 // Wait for the project to discover the git repository.
6402 cx.run_until_parked();
6403
6404 let panel = workspace.update_in(cx, |workspace, window, cx| {
6405 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6406 workspace.add_panel(panel.clone(), window, cx);
6407 panel
6408 });
6409
6410 cx.run_until_parked();
6411
6412 // Default should be LocalProject.
6413 panel.read_with(cx, |panel, _cx| {
6414 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
6415 });
6416
6417 // Change thread target to NewWorktree.
6418 panel.update_in(cx, |panel, window, cx| {
6419 panel.set_start_thread_in(
6420 &StartThreadIn::NewWorktree {
6421 worktree_name: None,
6422 branch_target: NewWorktreeBranchTarget::default(),
6423 },
6424 window,
6425 cx,
6426 );
6427 });
6428
6429 panel.read_with(cx, |panel, _cx| {
6430 assert_eq!(
6431 *panel.start_thread_in(),
6432 StartThreadIn::NewWorktree {
6433 worktree_name: None,
6434 branch_target: NewWorktreeBranchTarget::default(),
6435 },
6436 "thread target should be NewWorktree after set_thread_target"
6437 );
6438 });
6439
6440 // Let serialization complete.
6441 cx.run_until_parked();
6442
6443 // Load a fresh panel from the serialized data.
6444 let async_cx = cx.update(|window, cx| window.to_async(cx));
6445 let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
6446 .await
6447 .expect("panel load should succeed");
6448 cx.run_until_parked();
6449
6450 loaded_panel.read_with(cx, |panel, _cx| {
6451 assert_eq!(
6452 *panel.start_thread_in(),
6453 StartThreadIn::NewWorktree {
6454 worktree_name: None,
6455 branch_target: NewWorktreeBranchTarget::default(),
6456 },
6457 "thread target should survive serialization round-trip"
6458 );
6459 });
6460 }
6461
6462 #[gpui::test]
6463 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
6464 init_test(cx);
6465
6466 let fs = FakeFs::new(cx.executor());
6467 cx.update(|cx| {
6468 agent::ThreadStore::init_global(cx);
6469 language_model::LanguageModelRegistry::test(cx);
6470 <dyn fs::Fs>::set_global(fs.clone(), cx);
6471 });
6472
6473 fs.insert_tree(
6474 "/project",
6475 json!({
6476 ".git": {},
6477 "src": {
6478 "main.rs": "fn main() {}"
6479 }
6480 }),
6481 )
6482 .await;
6483
6484 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6485
6486 let multi_workspace =
6487 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6488
6489 let workspace = multi_workspace
6490 .read_with(cx, |multi_workspace, _cx| {
6491 multi_workspace.workspace().clone()
6492 })
6493 .unwrap();
6494
6495 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6496
6497 let panel = workspace.update_in(cx, |workspace, window, cx| {
6498 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6499 workspace.add_panel(panel.clone(), window, cx);
6500 panel
6501 });
6502
6503 cx.run_until_parked();
6504
6505 // set_active no longer creates threads — verify it's a no-op.
6506 panel.update_in(cx, |panel, window, cx| {
6507 panel.base_view = BaseView::Uninitialized;
6508 Panel::set_active(panel, true, window, cx);
6509 assert!(
6510 matches!(panel.base_view, BaseView::Uninitialized),
6511 "set_active should not create a thread"
6512 );
6513 });
6514 }
6515
6516 #[test]
6517 fn test_deserialize_agent_variants() {
6518 // PascalCase (legacy AgentType format, persisted in panel state)
6519 assert_eq!(
6520 serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
6521 Agent::NativeAgent,
6522 );
6523 assert_eq!(
6524 serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
6525 Agent::Custom {
6526 id: "my-agent".into(),
6527 },
6528 );
6529
6530 // Legacy TextThread variant deserializes to NativeAgent
6531 assert_eq!(
6532 serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
6533 Agent::NativeAgent,
6534 );
6535
6536 // snake_case (canonical format)
6537 assert_eq!(
6538 serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
6539 Agent::NativeAgent,
6540 );
6541 assert_eq!(
6542 serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
6543 Agent::Custom {
6544 id: "my-agent".into(),
6545 },
6546 );
6547
6548 // Serialization uses snake_case
6549 assert_eq!(
6550 serde_json::to_string(&Agent::NativeAgent).unwrap(),
6551 r#""native_agent""#,
6552 );
6553 assert_eq!(
6554 serde_json::to_string(&Agent::Custom {
6555 id: "my-agent".into()
6556 })
6557 .unwrap(),
6558 r#"{"custom":{"name":"my-agent"}}"#,
6559 );
6560 }
6561
6562 #[test]
6563 fn test_resolve_worktree_branch_target() {
6564 let existing_branches = HashSet::from_iter([
6565 "main".to_string(),
6566 "feature".to_string(),
6567 "origin/main".to_string(),
6568 ]);
6569
6570 let resolved = AgentPanel::resolve_worktree_branch_target(
6571 &NewWorktreeBranchTarget::CreateBranch {
6572 name: "new-branch".to_string(),
6573 from_ref: Some("main".to_string()),
6574 },
6575 &existing_branches,
6576 &HashSet::from_iter(["main".to_string()]),
6577 )
6578 .unwrap();
6579 assert_eq!(
6580 resolved,
6581 ("new-branch".to_string(), false, Some("main".to_string()))
6582 );
6583
6584 let resolved = AgentPanel::resolve_worktree_branch_target(
6585 &NewWorktreeBranchTarget::ExistingBranch {
6586 name: "feature".to_string(),
6587 },
6588 &existing_branches,
6589 &HashSet::default(),
6590 )
6591 .unwrap();
6592 assert_eq!(resolved, ("feature".to_string(), true, None));
6593
6594 let resolved = AgentPanel::resolve_worktree_branch_target(
6595 &NewWorktreeBranchTarget::ExistingBranch {
6596 name: "main".to_string(),
6597 },
6598 &existing_branches,
6599 &HashSet::from_iter(["main".to_string()]),
6600 )
6601 .unwrap();
6602 assert_eq!(resolved.1, false);
6603 assert_eq!(resolved.2, Some("main".to_string()));
6604 assert_ne!(resolved.0, "main");
6605 assert!(existing_branches.contains("main"));
6606 assert!(!existing_branches.contains(&resolved.0));
6607 }
6608
6609 #[gpui::test]
6610 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6611 init_test(cx);
6612
6613 let app_state = cx.update(|cx| {
6614 agent::ThreadStore::init_global(cx);
6615 language_model::LanguageModelRegistry::test(cx);
6616
6617 let app_state = workspace::AppState::test(cx);
6618 workspace::init(app_state.clone(), cx);
6619 app_state
6620 });
6621
6622 let fs = app_state.fs.as_fake();
6623 fs.insert_tree(
6624 "/project",
6625 json!({
6626 ".git": {},
6627 "src": {
6628 "main.rs": "fn main() {}"
6629 }
6630 }),
6631 )
6632 .await;
6633 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6634
6635 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6636
6637 let multi_workspace =
6638 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6639 multi_workspace
6640 .update(cx, |multi_workspace, _, cx| {
6641 multi_workspace.open_sidebar(cx);
6642 })
6643 .unwrap();
6644
6645 let workspace = multi_workspace
6646 .read_with(cx, |multi_workspace, _cx| {
6647 multi_workspace.workspace().clone()
6648 })
6649 .unwrap();
6650
6651 workspace.update(cx, |workspace, _cx| {
6652 workspace.set_random_database_id();
6653 });
6654
6655 // Register a callback so new workspaces also get an AgentPanel.
6656 cx.update(|cx| {
6657 cx.observe_new(
6658 |workspace: &mut Workspace,
6659 window: Option<&mut Window>,
6660 cx: &mut Context<Workspace>| {
6661 if let Some(window) = window {
6662 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6663 workspace.add_panel(panel, window, cx);
6664 }
6665 },
6666 )
6667 .detach();
6668 });
6669
6670 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6671
6672 // Wait for the project to discover the git repository.
6673 cx.run_until_parked();
6674
6675 let panel = workspace.update_in(cx, |workspace, window, cx| {
6676 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6677 workspace.add_panel(panel.clone(), window, cx);
6678 panel
6679 });
6680
6681 cx.run_until_parked();
6682
6683 // Open a thread (needed so there's an active thread view).
6684 panel.update_in(cx, |panel, window, cx| {
6685 panel.open_external_thread_with_server(
6686 Rc::new(StubAgentServer::default_response()),
6687 window,
6688 cx,
6689 );
6690 });
6691
6692 cx.run_until_parked();
6693
6694 // Set the selected agent to Codex (a custom agent) and start_thread_in
6695 // to NewWorktree. We do this AFTER opening the thread because
6696 // open_external_thread_with_server overrides selected_agent.
6697 panel.update_in(cx, |panel, window, cx| {
6698 panel.selected_agent = Agent::Custom {
6699 id: CODEX_ID.into(),
6700 };
6701 panel.set_start_thread_in(
6702 &StartThreadIn::NewWorktree {
6703 worktree_name: None,
6704 branch_target: NewWorktreeBranchTarget::default(),
6705 },
6706 window,
6707 cx,
6708 );
6709 });
6710
6711 // Verify the panel has the Codex agent selected.
6712 panel.read_with(cx, |panel, _cx| {
6713 assert_eq!(
6714 panel.selected_agent,
6715 Agent::Custom {
6716 id: CODEX_ID.into()
6717 },
6718 );
6719 });
6720
6721 // Directly call handle_worktree_creation_requested, which is what
6722 // handle_first_send_requested does when start_thread_in == NewWorktree.
6723 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6724 "Hello from test",
6725 ))];
6726 panel.update_in(cx, |panel, window, cx| {
6727 panel.handle_worktree_requested(
6728 content,
6729 WorktreeCreationArgs::New {
6730 worktree_name: None,
6731 branch_target: NewWorktreeBranchTarget::default(),
6732 },
6733 window,
6734 cx,
6735 );
6736 });
6737
6738 // Let the async worktree creation + workspace setup complete.
6739 cx.run_until_parked();
6740
6741 panel.read_with(cx, |panel, _cx| {
6742 assert_eq!(
6743 panel.start_thread_in(),
6744 &StartThreadIn::LocalProject,
6745 "the original panel should reset start_thread_in back to the local project after creating a worktree workspace",
6746 );
6747 });
6748
6749 // Find the new workspace's AgentPanel and verify it used the Codex agent.
6750 let found_codex = multi_workspace
6751 .read_with(cx, |multi_workspace, cx| {
6752 // There should be more than one workspace now (the original + the new worktree).
6753 assert!(
6754 multi_workspace.workspaces().count() > 1,
6755 "expected a new workspace to have been created, found {}",
6756 multi_workspace.workspaces().count(),
6757 );
6758
6759 // Check the newest workspace's panel for the correct agent.
6760 let new_workspace = multi_workspace
6761 .workspaces()
6762 .find(|ws| ws.entity_id() != workspace.entity_id())
6763 .expect("should find the new workspace");
6764 let new_panel = new_workspace
6765 .read(cx)
6766 .panel::<AgentPanel>(cx)
6767 .expect("new workspace should have an AgentPanel");
6768
6769 new_panel.read(cx).selected_agent.clone()
6770 })
6771 .unwrap();
6772
6773 assert_eq!(
6774 found_codex,
6775 Agent::Custom {
6776 id: CODEX_ID.into()
6777 },
6778 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6779 );
6780 }
6781
6782 #[gpui::test]
6783 async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
6784 use crate::thread_metadata_store::ThreadMetadataStore;
6785
6786 init_test(cx);
6787 cx.update(|cx| {
6788 agent::ThreadStore::init_global(cx);
6789 language_model::LanguageModelRegistry::test(cx);
6790 });
6791
6792 // Set up a project with one worktree.
6793 let fs = FakeFs::new(cx.executor());
6794 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
6795 .await;
6796 let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
6797
6798 let multi_workspace =
6799 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6800 let workspace = multi_workspace
6801 .read_with(cx, |mw, _cx| mw.workspace().clone())
6802 .unwrap();
6803 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
6804
6805 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
6806 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6807 });
6808
6809 // Open thread A and send a message. With empty next_prompt_updates it
6810 // stays generating, so opening B will move A to retained_threads.
6811 let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
6812 open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
6813 send_message(&panel, &mut cx);
6814 let session_id_a = active_session_id(&panel, &cx);
6815 let thread_id_a = active_thread_id(&panel, &cx);
6816
6817 // Open thread C — thread A (generating) moves to background.
6818 // Thread C completes immediately (idle), then opening B moves C to background too.
6819 let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
6820 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6821 acp::ContentChunk::new("done".into()),
6822 )]);
6823 open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
6824 send_message(&panel, &mut cx);
6825 let thread_id_c = active_thread_id(&panel, &cx);
6826
6827 // Open thread B — thread C (idle, non-loadable) is retained in background.
6828 let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
6829 open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
6830 send_message(&panel, &mut cx);
6831 let session_id_b = active_session_id(&panel, &cx);
6832 let _thread_id_b = active_thread_id(&panel, &cx);
6833
6834 let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
6835
6836 panel.read_with(&cx, |panel, _cx| {
6837 assert!(
6838 panel.retained_threads.contains_key(&thread_id_a),
6839 "Thread A should be in retained_threads"
6840 );
6841 assert!(
6842 panel.retained_threads.contains_key(&thread_id_c),
6843 "Thread C should be in retained_threads"
6844 );
6845 });
6846
6847 // Verify initial work_dirs for thread B contain only /project_a.
6848 let initial_b_paths = panel.read_with(&cx, |panel, cx| {
6849 let thread = panel.active_agent_thread(cx).unwrap();
6850 thread.read(cx).work_dirs().cloned().unwrap()
6851 });
6852 assert_eq!(
6853 initial_b_paths.ordered_paths().collect::<Vec<_>>(),
6854 vec![&PathBuf::from("/project_a")],
6855 "Thread B should initially have only /project_a"
6856 );
6857
6858 // Now add a second worktree to the project.
6859 fs.insert_tree("/project_b", json!({ "other.txt": "" }))
6860 .await;
6861 let (new_tree, _) = project
6862 .update(&mut cx, |project, cx| {
6863 project.find_or_create_worktree("/project_b", true, cx)
6864 })
6865 .await
6866 .unwrap();
6867 cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
6868 .await;
6869 cx.run_until_parked();
6870
6871 // Verify thread B's (active) work_dirs now include both worktrees.
6872 let updated_b_paths = panel.read_with(&cx, |panel, cx| {
6873 let thread = panel.active_agent_thread(cx).unwrap();
6874 thread.read(cx).work_dirs().cloned().unwrap()
6875 });
6876 let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
6877 b_paths_sorted.sort();
6878 assert_eq!(
6879 b_paths_sorted,
6880 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6881 "Thread B work_dirs should include both worktrees after adding /project_b"
6882 );
6883
6884 // Verify thread A's (background) work_dirs are also updated.
6885 let updated_a_paths = panel.read_with(&cx, |panel, cx| {
6886 let bg_view = panel.retained_threads.get(&thread_id_a).unwrap();
6887 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6888 root_thread
6889 .read(cx)
6890 .thread
6891 .read(cx)
6892 .work_dirs()
6893 .cloned()
6894 .unwrap()
6895 });
6896 let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
6897 a_paths_sorted.sort();
6898 assert_eq!(
6899 a_paths_sorted,
6900 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6901 "Thread A work_dirs should include both worktrees after adding /project_b"
6902 );
6903
6904 // Verify thread idle C was also updated.
6905 let updated_c_paths = panel.read_with(&cx, |panel, cx| {
6906 let bg_view = panel.retained_threads.get(&thread_id_c).unwrap();
6907 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6908 root_thread
6909 .read(cx)
6910 .thread
6911 .read(cx)
6912 .work_dirs()
6913 .cloned()
6914 .unwrap()
6915 });
6916 let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
6917 c_paths_sorted.sort();
6918 assert_eq!(
6919 c_paths_sorted,
6920 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6921 "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
6922 );
6923
6924 // Verify the metadata store reflects the new paths for running threads only.
6925 cx.run_until_parked();
6926 for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
6927 let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
6928 let metadata = store
6929 .entry_by_session(session_id)
6930 .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
6931 metadata.folder_paths().clone()
6932 });
6933 let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
6934 sorted.sort();
6935 assert_eq!(
6936 sorted,
6937 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6938 "{label} thread metadata folder_paths should include both worktrees"
6939 );
6940 }
6941
6942 // Now remove a worktree and verify work_dirs shrink.
6943 let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
6944 project.update(&mut cx, |project, cx| {
6945 project.remove_worktree(worktree_b_id, cx);
6946 });
6947 cx.run_until_parked();
6948
6949 let after_remove_b = panel.read_with(&cx, |panel, cx| {
6950 let thread = panel.active_agent_thread(cx).unwrap();
6951 thread.read(cx).work_dirs().cloned().unwrap()
6952 });
6953 assert_eq!(
6954 after_remove_b.ordered_paths().collect::<Vec<_>>(),
6955 vec![&PathBuf::from("/project_a")],
6956 "Thread B work_dirs should revert to only /project_a after removing /project_b"
6957 );
6958
6959 let after_remove_a = panel.read_with(&cx, |panel, cx| {
6960 let bg_view = panel.retained_threads.get(&thread_id_a).unwrap();
6961 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6962 root_thread
6963 .read(cx)
6964 .thread
6965 .read(cx)
6966 .work_dirs()
6967 .cloned()
6968 .unwrap()
6969 });
6970 assert_eq!(
6971 after_remove_a.ordered_paths().collect::<Vec<_>>(),
6972 vec![&PathBuf::from("/project_a")],
6973 "Thread A work_dirs should revert to only /project_a after removing /project_b"
6974 );
6975 }
6976
6977 #[gpui::test]
6978 async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
6979 init_test(cx);
6980 cx.update(|cx| {
6981 agent::ThreadStore::init_global(cx);
6982 language_model::LanguageModelRegistry::test(cx);
6983 // Use an isolated DB so parallel tests can't overwrite our global key.
6984 cx.set_global(db::AppDatabase::test_new());
6985 });
6986
6987 let custom_agent = Agent::Custom {
6988 id: "my-preferred-agent".into(),
6989 };
6990
6991 // Write a known agent to the global KVP to simulate a user who has
6992 // previously used this agent in another workspace.
6993 let kvp = cx.update(|cx| KeyValueStore::global(cx));
6994 write_global_last_used_agent(kvp, custom_agent.clone()).await;
6995
6996 let fs = FakeFs::new(cx.executor());
6997 let project = Project::test(fs.clone(), [], cx).await;
6998
6999 let multi_workspace =
7000 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7001
7002 let workspace = multi_workspace
7003 .read_with(cx, |multi_workspace, _cx| {
7004 multi_workspace.workspace().clone()
7005 })
7006 .unwrap();
7007
7008 workspace.update(cx, |workspace, _cx| {
7009 workspace.set_random_database_id();
7010 });
7011
7012 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7013
7014 // Load the panel via `load()`, which reads the global fallback
7015 // asynchronously when no per-workspace state exists.
7016 let async_cx = cx.update(|window, cx| window.to_async(cx));
7017 let panel = AgentPanel::load(workspace.downgrade(), async_cx)
7018 .await
7019 .expect("panel load should succeed");
7020 cx.run_until_parked();
7021
7022 panel.read_with(cx, |panel, _cx| {
7023 assert_eq!(
7024 panel.selected_agent, custom_agent,
7025 "new workspace should inherit the global last-used agent"
7026 );
7027 });
7028 }
7029
7030 #[gpui::test]
7031 async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
7032 init_test(cx);
7033 cx.update(|cx| {
7034 agent::ThreadStore::init_global(cx);
7035 language_model::LanguageModelRegistry::test(cx);
7036 });
7037
7038 let fs = FakeFs::new(cx.executor());
7039 let project_a = Project::test(fs.clone(), [], cx).await;
7040 let project_b = Project::test(fs, [], cx).await;
7041
7042 let multi_workspace =
7043 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
7044
7045 let workspace_a = multi_workspace
7046 .read_with(cx, |multi_workspace, _cx| {
7047 multi_workspace.workspace().clone()
7048 })
7049 .unwrap();
7050
7051 let workspace_b = multi_workspace
7052 .update(cx, |multi_workspace, window, cx| {
7053 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
7054 })
7055 .unwrap();
7056
7057 workspace_a.update(cx, |workspace, _cx| {
7058 workspace.set_random_database_id();
7059 });
7060 workspace_b.update(cx, |workspace, _cx| {
7061 workspace.set_random_database_id();
7062 });
7063
7064 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7065
7066 let agent_a = Agent::Custom {
7067 id: "agent-alpha".into(),
7068 };
7069 let agent_b = Agent::Custom {
7070 id: "agent-beta".into(),
7071 };
7072
7073 // Set up workspace A with agent_a
7074 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
7075 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
7076 });
7077 panel_a.update(cx, |panel, _cx| {
7078 panel.selected_agent = agent_a.clone();
7079 });
7080
7081 // Set up workspace B with agent_b
7082 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
7083 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
7084 });
7085 panel_b.update(cx, |panel, _cx| {
7086 panel.selected_agent = agent_b.clone();
7087 });
7088
7089 // Serialize both panels
7090 panel_a.update(cx, |panel, cx| panel.serialize(cx));
7091 panel_b.update(cx, |panel, cx| panel.serialize(cx));
7092 cx.run_until_parked();
7093
7094 // Load fresh panels from serialized state and verify independence
7095 let async_cx = cx.update(|window, cx| window.to_async(cx));
7096 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
7097 .await
7098 .expect("panel A load should succeed");
7099 cx.run_until_parked();
7100
7101 let async_cx = cx.update(|window, cx| window.to_async(cx));
7102 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
7103 .await
7104 .expect("panel B load should succeed");
7105 cx.run_until_parked();
7106
7107 loaded_a.read_with(cx, |panel, _cx| {
7108 assert_eq!(
7109 panel.selected_agent, agent_a,
7110 "workspace A should restore agent-alpha, not agent-beta"
7111 );
7112 });
7113
7114 loaded_b.read_with(cx, |panel, _cx| {
7115 assert_eq!(
7116 panel.selected_agent, agent_b,
7117 "workspace B should restore agent-beta, not agent-alpha"
7118 );
7119 });
7120 }
7121
7122 #[gpui::test]
7123 async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
7124 init_test(cx);
7125 cx.update(|cx| {
7126 agent::ThreadStore::init_global(cx);
7127 language_model::LanguageModelRegistry::test(cx);
7128 });
7129
7130 let fs = FakeFs::new(cx.executor());
7131 let project = Project::test(fs.clone(), [], cx).await;
7132
7133 let multi_workspace =
7134 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7135
7136 let workspace = multi_workspace
7137 .read_with(cx, |multi_workspace, _cx| {
7138 multi_workspace.workspace().clone()
7139 })
7140 .unwrap();
7141
7142 workspace.update(cx, |workspace, _cx| {
7143 workspace.set_random_database_id();
7144 });
7145
7146 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7147
7148 let custom_agent = Agent::Custom {
7149 id: "my-custom-agent".into(),
7150 };
7151
7152 let panel = workspace.update_in(cx, |workspace, window, cx| {
7153 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7154 workspace.add_panel(panel.clone(), window, cx);
7155 panel
7156 });
7157
7158 // Set selected_agent to a custom agent
7159 panel.update(cx, |panel, _cx| {
7160 panel.selected_agent = custom_agent.clone();
7161 });
7162
7163 // Call new_thread, which internally calls external_thread(None, ...)
7164 // This resolves the agent from self.selected_agent
7165 panel.update_in(cx, |panel, window, cx| {
7166 panel.new_thread(&NewThread, window, cx);
7167 });
7168
7169 panel.read_with(cx, |panel, _cx| {
7170 assert_eq!(
7171 panel.selected_agent, custom_agent,
7172 "selected_agent should remain the custom agent after new_thread"
7173 );
7174 assert!(
7175 panel.active_conversation_view().is_some(),
7176 "a thread should have been created"
7177 );
7178 });
7179 }
7180
7181 #[gpui::test]
7182 async fn test_rollback_all_succeed_returns_ok(cx: &mut TestAppContext) {
7183 init_test(cx);
7184 let fs = FakeFs::new(cx.executor());
7185 cx.update(|cx| {
7186 cx.update_flags(true, vec!["agent-v2".to_string()]);
7187 agent::ThreadStore::init_global(cx);
7188 language_model::LanguageModelRegistry::test(cx);
7189 <dyn fs::Fs>::set_global(fs.clone(), cx);
7190 });
7191
7192 fs.insert_tree(
7193 "/project",
7194 json!({
7195 ".git": {},
7196 "src": { "main.rs": "fn main() {}" }
7197 }),
7198 )
7199 .await;
7200
7201 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7202 cx.executor().run_until_parked();
7203
7204 let repository = project.read_with(cx, |project, cx| {
7205 project.repositories(cx).values().next().unwrap().clone()
7206 });
7207
7208 let multi_workspace =
7209 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7210
7211 let path_a = PathBuf::from("/worktrees/branch/project_a");
7212 let path_b = PathBuf::from("/worktrees/branch/project_b");
7213
7214 let (sender_a, receiver_a) = futures::channel::oneshot::channel::<Result<()>>();
7215 let (sender_b, receiver_b) = futures::channel::oneshot::channel::<Result<()>>();
7216 sender_a.send(Ok(())).unwrap();
7217 sender_b.send(Ok(())).unwrap();
7218
7219 let creation_infos = vec![
7220 (repository.clone(), path_a.clone(), receiver_a),
7221 (repository.clone(), path_b.clone(), receiver_b),
7222 ];
7223
7224 let fs_clone = fs.clone();
7225 let result = multi_workspace
7226 .update(cx, |_, window, cx| {
7227 window.spawn(cx, async move |cx| {
7228 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7229 })
7230 })
7231 .unwrap()
7232 .await;
7233
7234 let paths = result.expect("all succeed should return Ok");
7235 assert_eq!(paths, vec![path_a, path_b]);
7236 }
7237
7238 #[gpui::test]
7239 async fn test_rollback_on_failure_attempts_all_worktrees(cx: &mut TestAppContext) {
7240 init_test(cx);
7241 let fs = FakeFs::new(cx.executor());
7242 cx.update(|cx| {
7243 cx.update_flags(true, vec!["agent-v2".to_string()]);
7244 agent::ThreadStore::init_global(cx);
7245 language_model::LanguageModelRegistry::test(cx);
7246 <dyn fs::Fs>::set_global(fs.clone(), cx);
7247 });
7248
7249 fs.insert_tree(
7250 "/project",
7251 json!({
7252 ".git": {},
7253 "src": { "main.rs": "fn main() {}" }
7254 }),
7255 )
7256 .await;
7257
7258 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7259 cx.executor().run_until_parked();
7260
7261 let repository = project.read_with(cx, |project, cx| {
7262 project.repositories(cx).values().next().unwrap().clone()
7263 });
7264
7265 // Actually create a worktree so it exists in FakeFs for rollback to find.
7266 let success_path = PathBuf::from("/worktrees/branch/project");
7267 cx.update(|cx| {
7268 repository.update(cx, |repo, _| {
7269 repo.create_worktree(
7270 git::repository::CreateWorktreeTarget::NewBranch {
7271 branch_name: "branch".to_string(),
7272 base_sha: None,
7273 },
7274 success_path.clone(),
7275 )
7276 })
7277 })
7278 .await
7279 .unwrap()
7280 .unwrap();
7281 cx.executor().run_until_parked();
7282
7283 // Verify the worktree directory exists before rollback.
7284 assert!(
7285 fs.is_dir(&success_path).await,
7286 "worktree directory should exist before rollback"
7287 );
7288
7289 let multi_workspace =
7290 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7291
7292 // Build creation_infos: one success, one failure.
7293 let failed_path = PathBuf::from("/worktrees/branch/failed_project");
7294
7295 let (sender_ok, receiver_ok) = futures::channel::oneshot::channel::<Result<()>>();
7296 let (sender_err, receiver_err) = futures::channel::oneshot::channel::<Result<()>>();
7297 sender_ok.send(Ok(())).unwrap();
7298 sender_err
7299 .send(Err(anyhow!("branch already exists")))
7300 .unwrap();
7301
7302 let creation_infos = vec![
7303 (repository.clone(), success_path.clone(), receiver_ok),
7304 (repository.clone(), failed_path.clone(), receiver_err),
7305 ];
7306
7307 let fs_clone = fs.clone();
7308 let result = multi_workspace
7309 .update(cx, |_, window, cx| {
7310 window.spawn(cx, async move |cx| {
7311 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7312 })
7313 })
7314 .unwrap()
7315 .await;
7316
7317 assert!(
7318 result.is_err(),
7319 "should return error when any creation fails"
7320 );
7321 let err_msg = result.unwrap_err().to_string();
7322 assert!(
7323 err_msg.contains("branch already exists"),
7324 "error should mention the original failure: {err_msg}"
7325 );
7326
7327 // The successful worktree should have been rolled back by git.
7328 cx.executor().run_until_parked();
7329 assert!(
7330 !fs.is_dir(&success_path).await,
7331 "successful worktree directory should be removed by rollback"
7332 );
7333 }
7334
7335 #[gpui::test]
7336 async fn test_rollback_on_canceled_receiver(cx: &mut TestAppContext) {
7337 init_test(cx);
7338 let fs = FakeFs::new(cx.executor());
7339 cx.update(|cx| {
7340 cx.update_flags(true, vec!["agent-v2".to_string()]);
7341 agent::ThreadStore::init_global(cx);
7342 language_model::LanguageModelRegistry::test(cx);
7343 <dyn fs::Fs>::set_global(fs.clone(), cx);
7344 });
7345
7346 fs.insert_tree(
7347 "/project",
7348 json!({
7349 ".git": {},
7350 "src": { "main.rs": "fn main() {}" }
7351 }),
7352 )
7353 .await;
7354
7355 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7356 cx.executor().run_until_parked();
7357
7358 let repository = project.read_with(cx, |project, cx| {
7359 project.repositories(cx).values().next().unwrap().clone()
7360 });
7361
7362 let multi_workspace =
7363 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7364
7365 let path = PathBuf::from("/worktrees/branch/project");
7366
7367 // Drop the sender to simulate a canceled receiver.
7368 let (_sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
7369 drop(_sender);
7370
7371 let creation_infos = vec![(repository.clone(), path.clone(), receiver)];
7372
7373 let fs_clone = fs.clone();
7374 let result = multi_workspace
7375 .update(cx, |_, window, cx| {
7376 window.spawn(cx, async move |cx| {
7377 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7378 })
7379 })
7380 .unwrap()
7381 .await;
7382
7383 assert!(
7384 result.is_err(),
7385 "should return error when receiver is canceled"
7386 );
7387 let err_msg = result.unwrap_err().to_string();
7388 assert!(
7389 err_msg.contains("canceled"),
7390 "error should mention cancellation: {err_msg}"
7391 );
7392 }
7393
7394 #[gpui::test]
7395 async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) {
7396 init_test(cx);
7397 let fs = FakeFs::new(cx.executor());
7398 cx.update(|cx| {
7399 cx.update_flags(true, vec!["agent-v2".to_string()]);
7400 agent::ThreadStore::init_global(cx);
7401 language_model::LanguageModelRegistry::test(cx);
7402 <dyn fs::Fs>::set_global(fs.clone(), cx);
7403 });
7404
7405 fs.insert_tree(
7406 "/project",
7407 json!({
7408 ".git": {},
7409 "src": { "main.rs": "fn main() {}" }
7410 }),
7411 )
7412 .await;
7413
7414 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7415 cx.executor().run_until_parked();
7416
7417 let repository = project.read_with(cx, |project, cx| {
7418 project.repositories(cx).values().next().unwrap().clone()
7419 });
7420
7421 let multi_workspace =
7422 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7423
7424 // Simulate the orphan state: create_dir_all was called but git
7425 // worktree add failed, leaving a directory with leftover files.
7426 let orphan_path = PathBuf::from("/worktrees/branch/orphan_project");
7427 fs.insert_tree(
7428 "/worktrees/branch/orphan_project",
7429 json!({ "leftover.txt": "junk" }),
7430 )
7431 .await;
7432
7433 assert!(
7434 fs.is_dir(&orphan_path).await,
7435 "orphan dir should exist before rollback"
7436 );
7437
7438 let (sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
7439 sender.send(Err(anyhow!("hook failed"))).unwrap();
7440
7441 let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)];
7442
7443 let fs_clone = fs.clone();
7444 let result = multi_workspace
7445 .update(cx, |_, window, cx| {
7446 window.spawn(cx, async move |cx| {
7447 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7448 })
7449 })
7450 .unwrap()
7451 .await;
7452
7453 cx.executor().run_until_parked();
7454
7455 assert!(result.is_err());
7456 assert!(
7457 !fs.is_dir(&orphan_path).await,
7458 "orphan worktree directory should be removed by filesystem cleanup"
7459 );
7460 }
7461
7462 #[gpui::test]
7463 async fn test_worktree_creation_for_remote_project(
7464 cx: &mut TestAppContext,
7465 server_cx: &mut TestAppContext,
7466 ) {
7467 init_test(cx);
7468
7469 let app_state = cx.update(|cx| {
7470 agent::ThreadStore::init_global(cx);
7471 language_model::LanguageModelRegistry::test(cx);
7472
7473 let app_state = workspace::AppState::test(cx);
7474 workspace::init(app_state.clone(), cx);
7475 app_state
7476 });
7477
7478 server_cx.update(|cx| {
7479 release_channel::init(semver::Version::new(0, 0, 0), cx);
7480 });
7481
7482 // Set up the remote server side with a git repo.
7483 let server_fs = FakeFs::new(server_cx.executor());
7484 server_fs
7485 .insert_tree(
7486 "/project",
7487 json!({
7488 ".git": {},
7489 "src": {
7490 "main.rs": "fn main() {}"
7491 }
7492 }),
7493 )
7494 .await;
7495 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
7496
7497 // Create a mock remote connection.
7498 let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
7499
7500 server_cx.update(remote_server::HeadlessProject::init);
7501 let server_executor = server_cx.executor();
7502 let _headless = server_cx.new(|cx| {
7503 remote_server::HeadlessProject::new(
7504 remote_server::HeadlessAppState {
7505 session: server_session,
7506 fs: server_fs.clone(),
7507 http_client: Arc::new(http_client::BlockedHttpClient),
7508 node_runtime: node_runtime::NodeRuntime::unavailable(),
7509 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
7510 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
7511 startup_time: Instant::now(),
7512 },
7513 false,
7514 cx,
7515 )
7516 });
7517
7518 // Connect the client side and build a remote project.
7519 // Use a separate Client to avoid double-registering proto handlers
7520 // (Workspace::test_new creates its own WorkspaceStore from the
7521 // project's client).
7522 let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
7523 let project = cx.update(|cx| {
7524 let project_client = client::Client::new(
7525 Arc::new(clock::FakeSystemClock::new()),
7526 http_client::FakeHttpClient::with_404_response(),
7527 cx,
7528 );
7529 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
7530 project::Project::remote(
7531 remote_client,
7532 project_client,
7533 node_runtime::NodeRuntime::unavailable(),
7534 user_store,
7535 app_state.languages.clone(),
7536 app_state.fs.clone(),
7537 false,
7538 cx,
7539 )
7540 });
7541
7542 // Open the remote path as a worktree in the project.
7543 let worktree_path = Path::new("/project");
7544 project
7545 .update(cx, |project, cx| {
7546 project.find_or_create_worktree(worktree_path, true, cx)
7547 })
7548 .await
7549 .expect("should be able to open remote worktree");
7550 cx.run_until_parked();
7551
7552 // Verify the project is indeed remote.
7553 project.read_with(cx, |project, cx| {
7554 assert!(!project.is_local(), "project should be remote, not local");
7555 assert!(
7556 project.remote_connection_options(cx).is_some(),
7557 "project should have remote connection options"
7558 );
7559 });
7560
7561 // Create the workspace and agent panel.
7562 let multi_workspace =
7563 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7564 multi_workspace
7565 .update(cx, |multi_workspace, _, cx| {
7566 multi_workspace.open_sidebar(cx);
7567 })
7568 .unwrap();
7569
7570 let workspace = multi_workspace
7571 .read_with(cx, |mw, _cx| mw.workspace().clone())
7572 .unwrap();
7573
7574 workspace.update(cx, |workspace, _cx| {
7575 workspace.set_random_database_id();
7576 });
7577
7578 // Register a callback so new workspaces also get an AgentPanel.
7579 cx.update(|cx| {
7580 cx.observe_new(
7581 |workspace: &mut Workspace,
7582 window: Option<&mut Window>,
7583 cx: &mut Context<Workspace>| {
7584 if let Some(window) = window {
7585 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7586 workspace.add_panel(panel, window, cx);
7587 }
7588 },
7589 )
7590 .detach();
7591 });
7592
7593 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7594 cx.run_until_parked();
7595
7596 let panel = workspace.update_in(cx, |workspace, window, cx| {
7597 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7598 workspace.add_panel(panel.clone(), window, cx);
7599 panel
7600 });
7601
7602 cx.run_until_parked();
7603
7604 // Open a thread.
7605 panel.update_in(cx, |panel, window, cx| {
7606 panel.open_external_thread_with_server(
7607 Rc::new(StubAgentServer::default_response()),
7608 window,
7609 cx,
7610 );
7611 });
7612 cx.run_until_parked();
7613
7614 // Set start_thread_in to LinkedWorktree to bypass git worktree
7615 // creation and directly test workspace opening for a known path.
7616 let linked_path = PathBuf::from("/project");
7617 panel.update_in(cx, |panel, window, cx| {
7618 panel.set_start_thread_in(
7619 &StartThreadIn::LinkedWorktree {
7620 path: linked_path.clone(),
7621 display_name: "project".to_string(),
7622 },
7623 window,
7624 cx,
7625 );
7626 });
7627
7628 // Trigger worktree creation.
7629 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
7630 "Hello from remote test",
7631 ))];
7632 panel.update_in(cx, |panel, window, cx| {
7633 panel.handle_worktree_requested(
7634 content,
7635 WorktreeCreationArgs::Linked {
7636 worktree_path: linked_path,
7637 },
7638 window,
7639 cx,
7640 );
7641 });
7642
7643 // The refactored code uses `find_or_create_workspace`, which
7644 // finds the existing remote workspace (matching paths + host)
7645 // and reuses it instead of creating a new connection.
7646 cx.run_until_parked();
7647
7648 // The task should have completed: the existing workspace was
7649 // found and reused.
7650 panel.read_with(cx, |panel, _cx| {
7651 assert!(
7652 panel.worktree_creation_status.is_none(),
7653 "worktree creation should have completed, but status is: {:?}",
7654 panel.worktree_creation_status
7655 );
7656 });
7657
7658 // The existing remote workspace was reused — no new workspace
7659 // should have been created.
7660 multi_workspace
7661 .read_with(cx, |multi_workspace, cx| {
7662 let project = workspace.read(cx).project().clone();
7663 assert!(
7664 !project.read(cx).is_local(),
7665 "workspace project should still be remote, not local"
7666 );
7667 assert_eq!(
7668 multi_workspace.workspaces().count(),
7669 1,
7670 "existing remote workspace should be reused, not a new one created"
7671 );
7672 })
7673 .unwrap();
7674 }
7675
7676 #[gpui::test]
7677 async fn test_selected_agent_syncs_when_navigating_between_threads(cx: &mut TestAppContext) {
7678 let (panel, mut cx) = setup_panel(cx).await;
7679
7680 let stub_agent = Agent::Custom { id: "Test".into() };
7681
7682 // Open thread A and send a message so it is retained.
7683 let connection_a = StubAgentConnection::new();
7684 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7685 acp::ContentChunk::new("response a".into()),
7686 )]);
7687 open_thread_with_connection(&panel, connection_a, &mut cx);
7688 let session_id_a = active_session_id(&panel, &cx);
7689 send_message(&panel, &mut cx);
7690 cx.run_until_parked();
7691
7692 panel.read_with(&cx, |panel, _cx| {
7693 assert_eq!(panel.selected_agent, stub_agent);
7694 });
7695
7696 // Open thread B with a different agent — thread A goes to retained.
7697 let custom_agent = Agent::Custom {
7698 id: "my-custom-agent".into(),
7699 };
7700 let connection_b = StubAgentConnection::new()
7701 .with_agent_id("my-custom-agent".into())
7702 .with_telemetry_id("my-custom-agent".into());
7703 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7704 acp::ContentChunk::new("response b".into()),
7705 )]);
7706 open_thread_with_custom_connection(&panel, connection_b, &mut cx);
7707 send_message(&panel, &mut cx);
7708 cx.run_until_parked();
7709
7710 panel.read_with(&cx, |panel, _cx| {
7711 assert_eq!(
7712 panel.selected_agent, custom_agent,
7713 "selected_agent should have changed to the custom agent"
7714 );
7715 });
7716
7717 // Navigate back to thread A via load_agent_thread.
7718 panel.update_in(&mut cx, |panel, window, cx| {
7719 panel.load_agent_thread(
7720 stub_agent.clone(),
7721 session_id_a.clone(),
7722 None,
7723 None,
7724 true,
7725 window,
7726 cx,
7727 );
7728 });
7729
7730 panel.read_with(&cx, |panel, _cx| {
7731 assert_eq!(
7732 panel.selected_agent, stub_agent,
7733 "selected_agent should sync back to thread A's agent"
7734 );
7735 });
7736 }
7737
7738 #[gpui::test]
7739 async fn test_classify_worktrees_skips_non_git_root_with_nested_repo(cx: &mut TestAppContext) {
7740 init_test(cx);
7741 cx.update(|cx| {
7742 agent::ThreadStore::init_global(cx);
7743 language_model::LanguageModelRegistry::test(cx);
7744 });
7745
7746 let fs = FakeFs::new(cx.executor());
7747 fs.insert_tree(
7748 "/repo_a",
7749 json!({
7750 ".git": {},
7751 "src": { "main.rs": "" }
7752 }),
7753 )
7754 .await;
7755 fs.insert_tree(
7756 "/repo_b",
7757 json!({
7758 ".git": {},
7759 "src": { "lib.rs": "" }
7760 }),
7761 )
7762 .await;
7763 // `plain_dir` is NOT a git repo, but contains a nested git repo.
7764 fs.insert_tree(
7765 "/plain_dir",
7766 json!({
7767 "nested_repo": {
7768 ".git": {},
7769 "src": { "lib.rs": "" }
7770 }
7771 }),
7772 )
7773 .await;
7774
7775 let project = Project::test(
7776 fs.clone(),
7777 [
7778 Path::new("/repo_a"),
7779 Path::new("/repo_b"),
7780 Path::new("/plain_dir"),
7781 ],
7782 cx,
7783 )
7784 .await;
7785
7786 // Let the worktree scanner discover all `.git` directories.
7787 cx.executor().run_until_parked();
7788
7789 let multi_workspace =
7790 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7791
7792 let workspace = multi_workspace
7793 .read_with(cx, |mw, _cx| mw.workspace().clone())
7794 .unwrap();
7795
7796 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7797
7798 let panel = workspace.update_in(cx, |workspace, window, cx| {
7799 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
7800 });
7801
7802 cx.run_until_parked();
7803
7804 panel.read_with(cx, |panel, cx| {
7805 let (git_repos, non_git_paths) = panel.classify_worktrees(cx);
7806
7807 let git_work_dirs: Vec<PathBuf> = git_repos
7808 .iter()
7809 .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
7810 .collect();
7811
7812 assert_eq!(
7813 git_repos.len(),
7814 2,
7815 "only repo_a and repo_b should be classified as git repos, \
7816 but got: {git_work_dirs:?}"
7817 );
7818 assert!(
7819 git_work_dirs.contains(&PathBuf::from("/repo_a")),
7820 "repo_a should be in git_repos: {git_work_dirs:?}"
7821 );
7822 assert!(
7823 git_work_dirs.contains(&PathBuf::from("/repo_b")),
7824 "repo_b should be in git_repos: {git_work_dirs:?}"
7825 );
7826
7827 assert_eq!(
7828 non_git_paths,
7829 vec![PathBuf::from("/plain_dir")],
7830 "plain_dir should be classified as a non-git path \
7831 (not matched to nested_repo inside it)"
7832 );
7833 });
7834 }
7835}