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