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