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