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 existing_worktree_names: &[String],
2858 existing_worktree_paths: &HashSet<PathBuf>,
2859 branch_name: &str,
2860 use_existing_branch: bool,
2861 start_point: Option<String>,
2862 worktree_directory_setting: &str,
2863 cx: &mut Context<Self>,
2864 ) -> Result<(
2865 Vec<(
2866 Entity<project::git_store::Repository>,
2867 PathBuf,
2868 futures::channel::oneshot::Receiver<Result<()>>,
2869 )>,
2870 Vec<(PathBuf, PathBuf)>,
2871 )> {
2872 let mut creation_infos = Vec::new();
2873 let mut path_remapping = Vec::new();
2874
2875 let worktree_name = worktree_name.unwrap_or_else(|| {
2876 let existing_refs: Vec<&str> =
2877 existing_worktree_names.iter().map(|s| s.as_str()).collect();
2878 let mut rng = rand::rng();
2879 crate::branch_names::generate_branch_name(&existing_refs, &mut rng)
2880 .unwrap_or_else(|| branch_name.to_string())
2881 });
2882
2883 for repo in git_repos {
2884 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2885 let new_path =
2886 repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
2887 if existing_worktree_paths.contains(&new_path) {
2888 anyhow::bail!("A worktree already exists at {}", new_path.display());
2889 }
2890 let target = if use_existing_branch {
2891 debug_assert!(
2892 git_repos.len() == 1,
2893 "use_existing_branch should only be true for a single repo"
2894 );
2895 git::repository::CreateWorktreeTarget::ExistingBranch {
2896 branch_name: branch_name.to_string(),
2897 }
2898 } else {
2899 git::repository::CreateWorktreeTarget::NewBranch {
2900 branch_name: branch_name.to_string(),
2901 base_sha: start_point.clone(),
2902 }
2903 };
2904 let receiver = repo.create_worktree(target, new_path.clone());
2905 let work_dir = repo.work_directory_abs_path.clone();
2906 anyhow::Ok((work_dir, new_path, receiver))
2907 })?;
2908 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2909 creation_infos.push((repo.clone(), new_path, receiver));
2910 }
2911
2912 Ok((creation_infos, path_remapping))
2913 }
2914
2915 /// Waits for every in-flight worktree creation to complete. If any
2916 /// creation fails, all successfully-created worktrees are rolled back
2917 /// (removed) so the project isn't left in a half-migrated state.
2918 async fn await_and_rollback_on_failure(
2919 creation_infos: Vec<(
2920 Entity<project::git_store::Repository>,
2921 PathBuf,
2922 futures::channel::oneshot::Receiver<Result<()>>,
2923 )>,
2924 fs: Arc<dyn Fs>,
2925 cx: &mut AsyncWindowContext,
2926 ) -> Result<Vec<PathBuf>> {
2927 let mut created_paths: Vec<PathBuf> = Vec::new();
2928 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2929 Vec::new();
2930 let mut first_error: Option<anyhow::Error> = None;
2931
2932 for (repo, new_path, receiver) in creation_infos {
2933 repos_and_paths.push((repo.clone(), new_path.clone()));
2934 match receiver.await {
2935 Ok(Ok(())) => {
2936 created_paths.push(new_path);
2937 }
2938 Ok(Err(err)) => {
2939 if first_error.is_none() {
2940 first_error = Some(err);
2941 }
2942 }
2943 Err(_canceled) => {
2944 if first_error.is_none() {
2945 first_error = Some(anyhow!("Worktree creation was canceled"));
2946 }
2947 }
2948 }
2949 }
2950
2951 let Some(err) = first_error else {
2952 return Ok(created_paths);
2953 };
2954
2955 // Rollback all attempted worktrees (both successful and failed,
2956 // since a failed creation may have left an orphan directory).
2957 let mut rollback_futures = Vec::new();
2958 for (rollback_repo, rollback_path) in &repos_and_paths {
2959 let receiver = cx
2960 .update(|_, cx| {
2961 rollback_repo.update(cx, |repo, _cx| {
2962 repo.remove_worktree(rollback_path.clone(), true)
2963 })
2964 })
2965 .ok();
2966
2967 rollback_futures.push((rollback_path.clone(), receiver));
2968 }
2969
2970 let mut rollback_failures: Vec<String> = Vec::new();
2971 for (path, receiver_opt) in rollback_futures {
2972 let mut git_remove_failed = false;
2973
2974 if let Some(receiver) = receiver_opt {
2975 match receiver.await {
2976 Ok(Ok(())) => {}
2977 Ok(Err(rollback_err)) => {
2978 log::error!(
2979 "git worktree remove failed for {}: {rollback_err}",
2980 path.display()
2981 );
2982 git_remove_failed = true;
2983 }
2984 Err(canceled) => {
2985 log::error!(
2986 "git worktree remove failed for {}: {canceled}",
2987 path.display()
2988 );
2989 git_remove_failed = true;
2990 }
2991 }
2992 } else {
2993 log::error!(
2994 "failed to dispatch git worktree remove for {}",
2995 path.display()
2996 );
2997 git_remove_failed = true;
2998 }
2999
3000 // `git worktree remove` normally removes this directory, but since
3001 // `git worktree remove` failed (or wasn't dispatched), manually rm the directory.
3002 if git_remove_failed {
3003 if let Err(fs_err) = fs
3004 .remove_dir(
3005 &path,
3006 fs::RemoveOptions {
3007 recursive: true,
3008 ignore_if_not_exists: true,
3009 },
3010 )
3011 .await
3012 {
3013 let msg = format!("{}: failed to remove directory: {fs_err}", path.display());
3014 log::error!("{}", msg);
3015 rollback_failures.push(msg);
3016 }
3017 }
3018 }
3019 let mut error_message = format!("Failed to create worktree: {err}");
3020 if !rollback_failures.is_empty() {
3021 error_message.push_str("\n\nFailed to clean up: ");
3022 error_message.push_str(&rollback_failures.join(", "));
3023 }
3024 Err(anyhow!(error_message))
3025 }
3026
3027 fn set_worktree_creation_error(
3028 &mut self,
3029 message: SharedString,
3030 window: &mut Window,
3031 cx: &mut Context<Self>,
3032 ) {
3033 if let Some((_, status)) = &mut self.worktree_creation_status {
3034 *status = WorktreeCreationStatus::Error(message);
3035 }
3036 if matches!(self.active_view, ActiveView::Uninitialized) {
3037 let selected_agent = self.selected_agent.clone();
3038 self.new_agent_thread(selected_agent, window, cx);
3039 }
3040 cx.notify();
3041 }
3042
3043 fn handle_worktree_requested(
3044 &mut self,
3045 content: Vec<acp::ContentBlock>,
3046 args: WorktreeCreationArgs,
3047 window: &mut Window,
3048 cx: &mut Context<Self>,
3049 ) {
3050 if matches!(
3051 self.worktree_creation_status,
3052 Some((_, WorktreeCreationStatus::Creating))
3053 ) {
3054 return;
3055 }
3056
3057 let conversation_view_id = self
3058 .active_conversation_view()
3059 .map(|v| v.entity_id())
3060 .unwrap_or_else(|| EntityId::from(0u64));
3061 self.worktree_creation_status =
3062 Some((conversation_view_id, WorktreeCreationStatus::Creating));
3063 cx.notify();
3064
3065 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
3066
3067 if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
3068 self.set_worktree_creation_error(
3069 "No git repositories found in the project".into(),
3070 window,
3071 cx,
3072 );
3073 return;
3074 }
3075
3076 let (branch_receivers, worktree_receivers, worktree_directory_setting) =
3077 if matches!(args, WorktreeCreationArgs::New { .. }) {
3078 (
3079 Some(
3080 git_repos
3081 .iter()
3082 .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
3083 .collect::<Vec<_>>(),
3084 ),
3085 Some(
3086 git_repos
3087 .iter()
3088 .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
3089 .collect::<Vec<_>>(),
3090 ),
3091 Some(
3092 ProjectSettings::get_global(cx)
3093 .git
3094 .worktree_directory
3095 .clone(),
3096 ),
3097 )
3098 } else {
3099 (None, None, None)
3100 };
3101
3102 let active_file_path = self.workspace.upgrade().and_then(|workspace| {
3103 let workspace = workspace.read(cx);
3104 let active_item = workspace.active_item(cx)?;
3105 let project_path = active_item.project_path(cx)?;
3106 workspace
3107 .project()
3108 .read(cx)
3109 .absolute_path(&project_path, cx)
3110 });
3111
3112 let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
3113
3114 if remote_connection_options.is_some() {
3115 let is_disconnected = self
3116 .project
3117 .read(cx)
3118 .remote_client()
3119 .is_some_and(|client| client.read(cx).is_disconnected());
3120 if is_disconnected {
3121 self.set_worktree_creation_error(
3122 "Cannot create worktree: remote connection is not active".into(),
3123 window,
3124 cx,
3125 );
3126 return;
3127 }
3128 }
3129
3130 let workspace = self.workspace.clone();
3131 let window_handle = window
3132 .window_handle()
3133 .downcast::<workspace::MultiWorkspace>();
3134
3135 let selected_agent = self.selected_agent();
3136
3137 let task = cx.spawn_in(window, async move |this, cx| {
3138 let (all_paths, path_remapping, has_non_git) = match args {
3139 WorktreeCreationArgs::New {
3140 worktree_name,
3141 branch_target,
3142 } => {
3143 let branch_receivers = branch_receivers
3144 .expect("branch receivers must be prepared for new worktree creation");
3145 let worktree_receivers = worktree_receivers
3146 .expect("worktree receivers must be prepared for new worktree creation");
3147 let worktree_directory_setting = worktree_directory_setting
3148 .expect("worktree directory must be prepared for new worktree creation");
3149
3150 let mut existing_branches = HashSet::default();
3151 for result in futures::future::join_all(branch_receivers).await {
3152 match result {
3153 Ok(Ok(branches)) => {
3154 for branch in branches {
3155 existing_branches.insert(branch.name().to_string());
3156 }
3157 }
3158 Ok(Err(err)) => {
3159 Err::<(), _>(err).log_err();
3160 }
3161 Err(_) => {}
3162 }
3163 }
3164
3165 let mut occupied_branches = HashSet::default();
3166 let mut existing_worktree_names = Vec::new();
3167 let mut existing_worktree_paths = HashSet::default();
3168 for result in futures::future::join_all(worktree_receivers).await {
3169 match result {
3170 Ok(Ok(worktrees)) => {
3171 for worktree in worktrees {
3172 if let Some(branch_name) = worktree.branch_name() {
3173 occupied_branches.insert(branch_name.to_string());
3174 }
3175 if let Some(name) = worktree
3176 .path
3177 .parent()
3178 .and_then(|p| p.file_name())
3179 .and_then(|n| n.to_str())
3180 {
3181 existing_worktree_names.push(name.to_string());
3182 }
3183 existing_worktree_paths.insert(worktree.path.clone());
3184 }
3185 }
3186 Ok(Err(err)) => {
3187 Err::<(), _>(err).log_err();
3188 }
3189 Err(_) => {}
3190 }
3191 }
3192
3193 let (branch_name, use_existing_branch, start_point) =
3194 match Self::resolve_worktree_branch_target(
3195 &branch_target,
3196 &existing_branches,
3197 &occupied_branches,
3198 ) {
3199 Ok(target) => target,
3200 Err(err) => {
3201 this.update_in(cx, |this, window, cx| {
3202 this.set_worktree_creation_error(
3203 err.to_string().into(),
3204 window,
3205 cx,
3206 );
3207 })?;
3208 return anyhow::Ok(());
3209 }
3210 };
3211
3212 let (creation_infos, path_remapping) =
3213 match this.update_in(cx, |_this, _window, cx| {
3214 Self::start_worktree_creations(
3215 &git_repos,
3216 worktree_name,
3217 &existing_worktree_names,
3218 &existing_worktree_paths,
3219 &branch_name,
3220 use_existing_branch,
3221 start_point,
3222 &worktree_directory_setting,
3223 cx,
3224 )
3225 }) {
3226 Ok(Ok(result)) => result,
3227 Ok(Err(err)) | Err(err) => {
3228 this.update_in(cx, |this, window, cx| {
3229 this.set_worktree_creation_error(
3230 format!("Failed to validate worktree directory: {err}")
3231 .into(),
3232 window,
3233 cx,
3234 );
3235 })
3236 .log_err();
3237 return anyhow::Ok(());
3238 }
3239 };
3240
3241 let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
3242
3243 let created_paths =
3244 match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await {
3245 Ok(paths) => paths,
3246 Err(err) => {
3247 this.update_in(cx, |this, window, cx| {
3248 this.set_worktree_creation_error(
3249 format!("{err}").into(),
3250 window,
3251 cx,
3252 );
3253 })?;
3254 return anyhow::Ok(());
3255 }
3256 };
3257
3258 let mut all_paths = created_paths;
3259 let has_non_git = !non_git_paths.is_empty();
3260 all_paths.extend(non_git_paths.iter().cloned());
3261 (all_paths, path_remapping, has_non_git)
3262 }
3263 WorktreeCreationArgs::Linked { worktree_path } => {
3264 let mut all_paths = vec![worktree_path];
3265 let has_non_git = !non_git_paths.is_empty();
3266 all_paths.extend(non_git_paths.iter().cloned());
3267 (all_paths, Vec::new(), has_non_git)
3268 }
3269 };
3270
3271 if workspace.upgrade().is_none() {
3272 this.update_in(cx, |this, window, cx| {
3273 this.set_worktree_creation_error(
3274 "Workspace no longer available".into(),
3275 window,
3276 cx,
3277 );
3278 })?;
3279 return anyhow::Ok(());
3280 }
3281
3282 let this_for_error = this.clone();
3283 if let Err(err) = Self::open_worktree_workspace_and_start_thread(
3284 this,
3285 all_paths,
3286 window_handle,
3287 active_file_path,
3288 path_remapping,
3289 non_git_paths,
3290 has_non_git,
3291 content,
3292 selected_agent,
3293 remote_connection_options,
3294 cx,
3295 )
3296 .await
3297 {
3298 this_for_error
3299 .update_in(cx, |this, window, cx| {
3300 this.set_worktree_creation_error(
3301 format!("Failed to set up workspace: {err}").into(),
3302 window,
3303 cx,
3304 );
3305 })
3306 .log_err();
3307 }
3308 anyhow::Ok(())
3309 });
3310
3311 self._worktree_creation_task = Some(cx.background_spawn(async move {
3312 task.await.log_err();
3313 }));
3314 }
3315
3316 async fn open_worktree_workspace_and_start_thread(
3317 this: WeakEntity<Self>,
3318 all_paths: Vec<PathBuf>,
3319 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
3320 active_file_path: Option<PathBuf>,
3321 path_remapping: Vec<(PathBuf, PathBuf)>,
3322 non_git_paths: Vec<PathBuf>,
3323 has_non_git: bool,
3324 content: Vec<acp::ContentBlock>,
3325 selected_agent: Option<Agent>,
3326 remote_connection_options: Option<RemoteConnectionOptions>,
3327 cx: &mut AsyncWindowContext,
3328 ) -> Result<()> {
3329 let window_handle = window_handle
3330 .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
3331
3332 let (workspace_task, modal_workspace) =
3333 window_handle.update(cx, |multi_workspace, window, cx| {
3334 let path_list = PathList::new(&all_paths);
3335 let active_workspace = multi_workspace.workspace().clone();
3336 let modal_workspace = active_workspace.clone();
3337
3338 let task = multi_workspace.find_or_create_workspace(
3339 path_list,
3340 remote_connection_options,
3341 None,
3342 move |connection_options, window, cx| {
3343 remote_connection::connect_with_modal(
3344 &active_workspace,
3345 connection_options,
3346 window,
3347 cx,
3348 )
3349 },
3350 window,
3351 cx,
3352 );
3353 (task, modal_workspace)
3354 })?;
3355
3356 let result = workspace_task.await;
3357 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
3358 let new_workspace = result?;
3359
3360 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
3361
3362 if let Some(task) = panels_task {
3363 task.await.log_err();
3364 }
3365
3366 new_workspace
3367 .update(cx, |workspace, cx| {
3368 workspace.project().read(cx).wait_for_initial_scan(cx)
3369 })
3370 .await;
3371
3372 new_workspace
3373 .update(cx, |workspace, cx| {
3374 let repos = workspace
3375 .project()
3376 .read(cx)
3377 .repositories(cx)
3378 .values()
3379 .cloned()
3380 .collect::<Vec<_>>();
3381
3382 let tasks = repos
3383 .into_iter()
3384 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
3385 futures::future::join_all(tasks)
3386 })
3387 .await;
3388
3389 let initial_content = AgentInitialContent::ContentBlock {
3390 blocks: content,
3391 auto_submit: true,
3392 };
3393
3394 window_handle.update(cx, |_multi_workspace, window, cx| {
3395 new_workspace.update(cx, |workspace, cx| {
3396 if has_non_git {
3397 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
3398 workspace.show_toast(
3399 workspace::Toast::new(
3400 toast_id,
3401 "Some project folders are not git repositories. \
3402 They were included as-is without creating a worktree.",
3403 ),
3404 cx,
3405 );
3406 }
3407
3408 // If we had an active buffer, remap its path and reopen it.
3409 let had_active_file = active_file_path.is_some();
3410 let remapped_active_path = active_file_path.and_then(|original_path| {
3411 let best_match = path_remapping
3412 .iter()
3413 .filter_map(|(old_root, new_root)| {
3414 original_path.strip_prefix(old_root).ok().map(|relative| {
3415 (old_root.components().count(), new_root.join(relative))
3416 })
3417 })
3418 .max_by_key(|(depth, _)| *depth);
3419
3420 if let Some((_, remapped_path)) = best_match {
3421 return Some(remapped_path);
3422 }
3423
3424 for non_git in &non_git_paths {
3425 if original_path.starts_with(non_git) {
3426 return Some(original_path);
3427 }
3428 }
3429 None
3430 });
3431
3432 if had_active_file && remapped_active_path.is_none() {
3433 log::warn!(
3434 "Active file could not be remapped to the new worktree; it will not be reopened"
3435 );
3436 }
3437
3438 if let Some(path) = remapped_active_path {
3439 let open_task = workspace.open_paths(
3440 vec![path],
3441 workspace::OpenOptions::default(),
3442 None,
3443 window,
3444 cx,
3445 );
3446 cx.spawn(async move |_, _| -> anyhow::Result<()> {
3447 for item in open_task.await.into_iter().flatten() {
3448 item?;
3449 }
3450 Ok(())
3451 })
3452 .detach_and_log_err(cx);
3453 }
3454
3455 workspace.focus_panel::<AgentPanel>(window, cx);
3456
3457 // If no active buffer was open, zoom the agent panel
3458 // (equivalent to cmd-esc fullscreen behavior).
3459 // This must happen after focus_panel, which activates
3460 // and opens the panel in the dock.
3461
3462 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3463 panel.update(cx, |panel, cx| {
3464 panel.external_thread(
3465 selected_agent,
3466 None,
3467 None,
3468 None,
3469 Some(initial_content),
3470 true,
3471 window,
3472 cx,
3473 );
3474 });
3475 }
3476 });
3477 })?;
3478
3479 window_handle.update(cx, |multi_workspace, window, cx| {
3480 multi_workspace.activate(new_workspace.clone(), window, cx);
3481
3482 new_workspace.update(cx, |workspace, cx| {
3483 workspace.run_create_worktree_tasks(window, cx);
3484 })
3485 })?;
3486
3487 this.update_in(cx, |this, window, cx| {
3488 this.worktree_creation_status = None;
3489
3490 if let Some(thread_view) = this.active_thread_view(cx) {
3491 thread_view.update(cx, |thread_view, cx| {
3492 thread_view
3493 .message_editor
3494 .update(cx, |editor, cx| editor.clear(window, cx));
3495 });
3496 }
3497
3498 this.start_thread_in = StartThreadIn::LocalProject;
3499 this.serialize(cx);
3500 cx.notify();
3501 })?;
3502
3503 anyhow::Ok(())
3504 }
3505}
3506
3507impl Focusable for AgentPanel {
3508 fn focus_handle(&self, cx: &App) -> FocusHandle {
3509 match &self.active_view {
3510 ActiveView::Uninitialized => self.focus_handle.clone(),
3511 ActiveView::AgentThread {
3512 conversation_view, ..
3513 } => conversation_view.focus_handle(cx),
3514 ActiveView::History { view } => view.read(cx).focus_handle(cx),
3515 ActiveView::Configuration => {
3516 if let Some(configuration) = self.configuration.as_ref() {
3517 configuration.focus_handle(cx)
3518 } else {
3519 self.focus_handle.clone()
3520 }
3521 }
3522 }
3523 }
3524}
3525
3526fn agent_panel_dock_position(cx: &App) -> DockPosition {
3527 AgentSettings::get_global(cx).dock.into()
3528}
3529
3530pub enum AgentPanelEvent {
3531 ActiveViewChanged,
3532 ThreadFocused,
3533 BackgroundThreadChanged,
3534 MessageSentOrQueued { session_id: acp::SessionId },
3535}
3536
3537impl EventEmitter<PanelEvent> for AgentPanel {}
3538impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3539
3540impl Panel for AgentPanel {
3541 fn persistent_name() -> &'static str {
3542 "AgentPanel"
3543 }
3544
3545 fn panel_key() -> &'static str {
3546 AGENT_PANEL_KEY
3547 }
3548
3549 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3550 agent_panel_dock_position(cx)
3551 }
3552
3553 fn position_is_valid(&self, position: DockPosition) -> bool {
3554 position != DockPosition::Bottom
3555 }
3556
3557 fn set_position(&mut self, position: DockPosition, _: &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_dock(position.into());
3563 });
3564 }
3565
3566 fn default_size(&self, window: &Window, cx: &App) -> Pixels {
3567 let settings = AgentSettings::get_global(cx);
3568 match self.position(window, cx) {
3569 DockPosition::Left | DockPosition::Right => settings.default_width,
3570 DockPosition::Bottom => settings.default_height,
3571 }
3572 }
3573
3574 fn supports_flexible_size(&self) -> bool {
3575 true
3576 }
3577
3578 fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
3579 AgentSettings::get_global(cx).flexible
3580 }
3581
3582 fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
3583 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3584 settings
3585 .agent
3586 .get_or_insert_default()
3587 .set_flexible_size(flexible);
3588 });
3589 }
3590
3591 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
3592 if active
3593 && matches!(self.active_view, ActiveView::Uninitialized)
3594 && !matches!(
3595 self.worktree_creation_status,
3596 Some((_, WorktreeCreationStatus::Creating))
3597 )
3598 {
3599 let id = self.create_draft(window, cx);
3600 self.activate_draft(id, false, window, cx);
3601 }
3602 }
3603
3604 fn remote_id() -> Option<proto::PanelId> {
3605 Some(proto::PanelId::AssistantPanel)
3606 }
3607
3608 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3609 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3610 }
3611
3612 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3613 Some("Agent Panel")
3614 }
3615
3616 fn toggle_action(&self) -> Box<dyn Action> {
3617 Box::new(ToggleFocus)
3618 }
3619
3620 fn activation_priority(&self) -> u32 {
3621 0
3622 }
3623
3624 fn enabled(&self, cx: &App) -> bool {
3625 AgentSettings::get_global(cx).enabled(cx)
3626 }
3627
3628 fn is_agent_panel(&self) -> bool {
3629 true
3630 }
3631
3632 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3633 self.zoomed
3634 }
3635
3636 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3637 self.zoomed = zoomed;
3638 cx.notify();
3639 }
3640}
3641
3642impl AgentPanel {
3643 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3644 let content = match &self.active_view {
3645 ActiveView::AgentThread { conversation_view } => {
3646 let server_view_ref = conversation_view.read(cx);
3647 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3648 && server_view_ref.root_thread(cx).map_or(false, |tv| {
3649 tv.read(cx).thread.read(cx).has_provisional_title()
3650 });
3651
3652 if let Some(title_editor) = server_view_ref
3653 .root_thread(cx)
3654 .map(|r| r.read(cx).title_editor.clone())
3655 {
3656 if is_generating_title {
3657 Label::new(DEFAULT_THREAD_TITLE)
3658 .color(Color::Muted)
3659 .truncate()
3660 .with_animation(
3661 "generating_title",
3662 Animation::new(Duration::from_secs(2))
3663 .repeat()
3664 .with_easing(pulsating_between(0.4, 0.8)),
3665 |label, delta| label.alpha(delta),
3666 )
3667 .into_any_element()
3668 } else {
3669 div()
3670 .w_full()
3671 .on_action({
3672 let conversation_view = conversation_view.downgrade();
3673 move |_: &menu::Confirm, window, cx| {
3674 if let Some(conversation_view) = conversation_view.upgrade() {
3675 conversation_view.focus_handle(cx).focus(window, cx);
3676 }
3677 }
3678 })
3679 .on_action({
3680 let conversation_view = conversation_view.downgrade();
3681 move |_: &editor::actions::Cancel, window, cx| {
3682 if let Some(conversation_view) = conversation_view.upgrade() {
3683 conversation_view.focus_handle(cx).focus(window, cx);
3684 }
3685 }
3686 })
3687 .child(title_editor)
3688 .into_any_element()
3689 }
3690 } else {
3691 Label::new(conversation_view.read(cx).title(cx))
3692 .color(Color::Muted)
3693 .truncate()
3694 .into_any_element()
3695 }
3696 }
3697 ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
3698 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
3699 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3700 };
3701
3702 h_flex()
3703 .key_context("TitleEditor")
3704 .id("TitleEditor")
3705 .flex_grow()
3706 .w_full()
3707 .max_w_full()
3708 .overflow_x_scroll()
3709 .child(content)
3710 .into_any()
3711 }
3712
3713 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3714 conversation_view.update(cx, |conversation_view, cx| {
3715 if let Some(thread) = conversation_view.as_native_thread(cx) {
3716 thread.update(cx, |thread, cx| {
3717 thread.generate_title(cx);
3718 });
3719 }
3720 });
3721 }
3722
3723 fn render_panel_options_menu(
3724 &self,
3725 _window: &mut Window,
3726 cx: &mut Context<Self>,
3727 ) -> impl IntoElement {
3728 let focus_handle = self.focus_handle(cx);
3729
3730 let conversation_view = match &self.active_view {
3731 ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3732 _ => None,
3733 };
3734 let thread_with_messages = match &self.active_view {
3735 ActiveView::AgentThread { conversation_view } => {
3736 conversation_view.read(cx).has_user_submitted_prompt(cx)
3737 }
3738 _ => false,
3739 };
3740 let has_auth_methods = match &self.active_view {
3741 ActiveView::AgentThread { conversation_view } => {
3742 conversation_view.read(cx).has_auth_methods()
3743 }
3744 _ => false,
3745 };
3746
3747 PopoverMenu::new("agent-options-menu")
3748 .trigger_with_tooltip(
3749 IconButton::new("agent-options-menu", IconName::Ellipsis)
3750 .icon_size(IconSize::Small),
3751 {
3752 let focus_handle = focus_handle.clone();
3753 move |_window, cx| {
3754 Tooltip::for_action_in(
3755 "Toggle Agent Menu",
3756 &ToggleOptionsMenu,
3757 &focus_handle,
3758 cx,
3759 )
3760 }
3761 },
3762 )
3763 .anchor(Corner::TopRight)
3764 .with_handle(self.agent_panel_menu_handle.clone())
3765 .menu({
3766 move |window, cx| {
3767 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3768 menu = menu.context(focus_handle.clone());
3769
3770 if thread_with_messages {
3771 menu = menu.header("Current Thread");
3772
3773 if let Some(conversation_view) = conversation_view.as_ref() {
3774 menu = menu
3775 .entry("Regenerate Thread Title", None, {
3776 let conversation_view = conversation_view.clone();
3777 move |_, cx| {
3778 Self::handle_regenerate_thread_title(
3779 conversation_view.clone(),
3780 cx,
3781 );
3782 }
3783 })
3784 .separator();
3785 }
3786 }
3787
3788 menu = menu
3789 .header("MCP Servers")
3790 .action(
3791 "View Server Extensions",
3792 Box::new(zed_actions::Extensions {
3793 category_filter: Some(
3794 zed_actions::ExtensionCategoryFilter::ContextServers,
3795 ),
3796 id: None,
3797 }),
3798 )
3799 .action("Add Custom Server…", Box::new(AddContextServer))
3800 .separator()
3801 .action("Rules", Box::new(OpenRulesLibrary::default()))
3802 .action("Profiles", Box::new(ManageProfiles::default()))
3803 .action("Settings", Box::new(OpenSettings))
3804 .separator()
3805 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
3806
3807 if has_auth_methods {
3808 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3809 }
3810
3811 menu
3812 }))
3813 }
3814 })
3815 }
3816
3817 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3818 let focus_handle = self.focus_handle(cx);
3819
3820 IconButton::new("go-back", IconName::ArrowLeft)
3821 .icon_size(IconSize::Small)
3822 .on_click(cx.listener(|this, _, window, cx| {
3823 this.go_back(&workspace::GoBack, window, cx);
3824 }))
3825 .tooltip({
3826 move |_window, cx| {
3827 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3828 }
3829 })
3830 }
3831
3832 fn project_has_git_repository(&self, cx: &App) -> bool {
3833 !self.project.read(cx).repositories(cx).is_empty()
3834 }
3835
3836 fn is_active_view_creating_worktree(&self, _cx: &App) -> bool {
3837 match &self.worktree_creation_status {
3838 Some((view_id, WorktreeCreationStatus::Creating)) => {
3839 self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id)
3840 }
3841 _ => false,
3842 }
3843 }
3844
3845 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3846 let focus_handle = self.focus_handle(cx);
3847
3848 let is_creating = self.is_active_view_creating_worktree(cx);
3849
3850 let trigger_parts = self
3851 .start_thread_in
3852 .trigger_label(self.project.read(cx), cx);
3853
3854 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3855 IconName::ChevronUp
3856 } else {
3857 IconName::ChevronDown
3858 };
3859
3860 let trigger_button = ButtonLike::new("thread-target-trigger")
3861 .disabled(is_creating)
3862 .when_some(trigger_parts.prefix, |this, prefix| {
3863 this.child(Label::new(prefix).color(Color::Muted))
3864 })
3865 .child(Label::new(trigger_parts.label))
3866 .when_some(trigger_parts.suffix, |this, suffix| {
3867 this.child(Label::new(suffix).color(Color::Muted))
3868 })
3869 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3870
3871 let project = self.project.clone();
3872 let current_target = self.start_thread_in.clone();
3873 let fs = self.fs.clone();
3874
3875 PopoverMenu::new("thread-target-selector")
3876 .trigger_with_tooltip(trigger_button, {
3877 move |_window, cx| {
3878 Tooltip::for_action_in(
3879 "Start Thread In…",
3880 &CycleStartThreadIn,
3881 &focus_handle,
3882 cx,
3883 )
3884 }
3885 })
3886 .menu(move |window, cx| {
3887 let fs = fs.clone();
3888 Some(cx.new(|cx| {
3889 ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
3890 }))
3891 })
3892 .with_handle(self.start_thread_in_menu_handle.clone())
3893 .anchor(Corner::TopLeft)
3894 .offset(gpui::Point {
3895 x: px(1.0),
3896 y: px(1.0),
3897 })
3898 }
3899
3900 fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3901 let is_creating = self.is_active_view_creating_worktree(cx);
3902
3903 let project_ref = self.project.read(cx);
3904 let trigger_parts = self
3905 .start_thread_in
3906 .branch_trigger_label(project_ref, cx)
3907 .unwrap_or_else(|| StartThreadInLabel {
3908 prefix: Some("From:".into()),
3909 label: "HEAD".into(),
3910 suffix: None,
3911 });
3912
3913 let icon = if self.thread_branch_menu_handle.is_deployed() {
3914 IconName::ChevronUp
3915 } else {
3916 IconName::ChevronDown
3917 };
3918
3919 let trigger_button = ButtonLike::new("thread-branch-trigger")
3920 .disabled(is_creating)
3921 .when_some(trigger_parts.prefix, |this, prefix| {
3922 this.child(Label::new(prefix).color(Color::Muted))
3923 })
3924 .child(Label::new(trigger_parts.label))
3925 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3926
3927 let project = self.project.clone();
3928 let current_target = self.start_thread_in.clone();
3929
3930 PopoverMenu::new("thread-branch-selector")
3931 .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
3932 .menu(move |window, cx| {
3933 Some(cx.new(|cx| {
3934 ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
3935 }))
3936 })
3937 .with_handle(self.thread_branch_menu_handle.clone())
3938 .anchor(Corner::TopLeft)
3939 .offset(gpui::Point {
3940 x: px(1.0),
3941 y: px(1.0),
3942 })
3943 }
3944
3945 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3946 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3947 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3948 let focus_handle = self.focus_handle(cx);
3949
3950 let (selected_agent_custom_icon, selected_agent_label) =
3951 if let Agent::Custom { id, .. } = &self.selected_agent {
3952 let store = agent_server_store.read(cx);
3953 let icon = store.agent_icon(&id);
3954
3955 let label = store
3956 .agent_display_name(&id)
3957 .unwrap_or_else(|| self.selected_agent.label());
3958 (icon, label)
3959 } else {
3960 (None, self.selected_agent.label())
3961 };
3962
3963 let active_thread = match &self.active_view {
3964 ActiveView::AgentThread { conversation_view } => {
3965 conversation_view.read(cx).as_native_thread(cx)
3966 }
3967 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3968 None
3969 }
3970 };
3971
3972 let new_thread_menu_builder: Rc<
3973 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3974 > = {
3975 let selected_agent = self.selected_agent.clone();
3976 let is_agent_selected = move |agent: Agent| selected_agent == agent;
3977
3978 let workspace = self.workspace.clone();
3979 let is_via_collab = workspace
3980 .update(cx, |workspace, cx| {
3981 workspace.project().read(cx).is_via_collab()
3982 })
3983 .unwrap_or_default();
3984
3985 let focus_handle = focus_handle.clone();
3986 let agent_server_store = agent_server_store;
3987
3988 Rc::new(move |window, cx| {
3989 telemetry::event!("New Thread Clicked");
3990
3991 let active_thread = active_thread.clone();
3992 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3993 menu.context(focus_handle.clone())
3994 .when_some(active_thread, |this, active_thread| {
3995 let thread = active_thread.read(cx);
3996
3997 if !thread.is_empty() {
3998 let session_id = thread.id().clone();
3999 this.item(
4000 ContextMenuEntry::new("New From Summary")
4001 .icon(IconName::ThreadFromSummary)
4002 .icon_color(Color::Muted)
4003 .handler(move |window, cx| {
4004 window.dispatch_action(
4005 Box::new(NewNativeAgentThreadFromSummary {
4006 from_session_id: session_id.clone(),
4007 }),
4008 cx,
4009 );
4010 }),
4011 )
4012 } else {
4013 this
4014 }
4015 })
4016 .item(
4017 ContextMenuEntry::new("Zed Agent")
4018 .when(is_agent_selected(Agent::NativeAgent), |this| {
4019 this.action(Box::new(NewExternalAgentThread { agent: None }))
4020 })
4021 .icon(IconName::ZedAgent)
4022 .icon_color(Color::Muted)
4023 .handler({
4024 let workspace = workspace.clone();
4025 move |window, cx| {
4026 if let Some(workspace) = workspace.upgrade() {
4027 workspace.update(cx, |workspace, cx| {
4028 if let Some(panel) =
4029 workspace.panel::<AgentPanel>(cx)
4030 {
4031 panel.update(cx, |panel, cx| {
4032 panel.new_agent_thread(
4033 Agent::NativeAgent,
4034 window,
4035 cx,
4036 );
4037 });
4038 }
4039 });
4040 }
4041 }
4042 }),
4043 )
4044 .map(|mut menu| {
4045 let agent_server_store = agent_server_store.read(cx);
4046 let registry_store = project::AgentRegistryStore::try_global(cx);
4047 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
4048
4049 struct AgentMenuItem {
4050 id: AgentId,
4051 display_name: SharedString,
4052 }
4053
4054 let agent_items = agent_server_store
4055 .external_agents()
4056 .map(|agent_id| {
4057 let display_name = agent_server_store
4058 .agent_display_name(agent_id)
4059 .or_else(|| {
4060 registry_store_ref
4061 .as_ref()
4062 .and_then(|store| store.agent(agent_id))
4063 .map(|a| a.name().clone())
4064 })
4065 .unwrap_or_else(|| agent_id.0.clone());
4066 AgentMenuItem {
4067 id: agent_id.clone(),
4068 display_name,
4069 }
4070 })
4071 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
4072 .collect::<Vec<_>>();
4073
4074 if !agent_items.is_empty() {
4075 menu = menu.separator().header("External Agents");
4076 }
4077 for item in &agent_items {
4078 let mut entry = ContextMenuEntry::new(item.display_name.clone());
4079
4080 let icon_path =
4081 agent_server_store.agent_icon(&item.id).or_else(|| {
4082 registry_store_ref
4083 .as_ref()
4084 .and_then(|store| store.agent(&item.id))
4085 .and_then(|a| a.icon_path().cloned())
4086 });
4087
4088 if let Some(icon_path) = icon_path {
4089 entry = entry.custom_icon_svg(icon_path);
4090 } else {
4091 entry = entry.icon(IconName::Sparkle);
4092 }
4093
4094 entry = entry
4095 .when(
4096 is_agent_selected(Agent::Custom {
4097 id: item.id.clone(),
4098 }),
4099 |this| {
4100 this.action(Box::new(NewExternalAgentThread {
4101 agent: None,
4102 }))
4103 },
4104 )
4105 .icon_color(Color::Muted)
4106 .disabled(is_via_collab)
4107 .handler({
4108 let workspace = workspace.clone();
4109 let agent_id = item.id.clone();
4110 move |window, cx| {
4111 if let Some(workspace) = workspace.upgrade() {
4112 workspace.update(cx, |workspace, cx| {
4113 if let Some(panel) =
4114 workspace.panel::<AgentPanel>(cx)
4115 {
4116 panel.update(cx, |panel, cx| {
4117 panel.new_agent_thread(
4118 Agent::Custom {
4119 id: agent_id.clone(),
4120 },
4121 window,
4122 cx,
4123 );
4124 });
4125 }
4126 });
4127 }
4128 }
4129 });
4130
4131 menu = menu.item(entry);
4132 }
4133
4134 menu
4135 })
4136 .separator()
4137 .item(
4138 ContextMenuEntry::new("Add More Agents")
4139 .icon(IconName::Plus)
4140 .icon_color(Color::Muted)
4141 .handler({
4142 move |window, cx| {
4143 window
4144 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
4145 }
4146 }),
4147 )
4148 }))
4149 })
4150 };
4151
4152 let is_thread_loading = self
4153 .active_conversation_view()
4154 .map(|thread| thread.read(cx).is_loading())
4155 .unwrap_or(false);
4156
4157 let has_custom_icon = selected_agent_custom_icon.is_some();
4158 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
4159 let selected_agent_builtin_icon = self.selected_agent.icon();
4160 let selected_agent_label_for_tooltip = selected_agent_label.clone();
4161
4162 let selected_agent = div()
4163 .id("selected_agent_icon")
4164 .when_some(selected_agent_custom_icon, |this, icon_path| {
4165 this.px_1()
4166 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
4167 })
4168 .when(!has_custom_icon, |this| {
4169 this.when_some(selected_agent_builtin_icon, |this, icon| {
4170 this.px_1().child(Icon::new(icon).color(Color::Muted))
4171 })
4172 })
4173 .tooltip(move |_, cx| {
4174 Tooltip::with_meta(
4175 selected_agent_label_for_tooltip.clone(),
4176 None,
4177 "Selected Agent",
4178 cx,
4179 )
4180 });
4181
4182 let selected_agent = if is_thread_loading {
4183 selected_agent
4184 .with_animation(
4185 "pulsating-icon",
4186 Animation::new(Duration::from_secs(1))
4187 .repeat()
4188 .with_easing(pulsating_between(0.2, 0.6)),
4189 |icon, delta| icon.opacity(delta),
4190 )
4191 .into_any_element()
4192 } else {
4193 selected_agent.into_any_element()
4194 };
4195
4196 let is_empty_state = !self.active_thread_has_messages(cx);
4197
4198 let is_in_history_or_config = matches!(
4199 &self.active_view,
4200 ActiveView::History { .. } | ActiveView::Configuration
4201 );
4202
4203 let is_full_screen = self.is_zoomed(window, cx);
4204 let full_screen_button = if is_full_screen {
4205 IconButton::new("disable-full-screen", IconName::Minimize)
4206 .icon_size(IconSize::Small)
4207 .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
4208 .on_click(cx.listener(move |this, _, window, cx| {
4209 this.toggle_zoom(&ToggleZoom, window, cx);
4210 }))
4211 } else {
4212 IconButton::new("enable-full-screen", IconName::Maximize)
4213 .icon_size(IconSize::Small)
4214 .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
4215 .on_click(cx.listener(move |this, _, window, cx| {
4216 this.toggle_zoom(&ToggleZoom, window, cx);
4217 }))
4218 };
4219
4220 let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
4221
4222 let max_content_width = AgentSettings::get_global(cx).max_content_width;
4223
4224 let base_container = h_flex()
4225 .size_full()
4226 // TODO: This is only until we remove Agent settings from the panel.
4227 .when(!is_in_history_or_config, |this| {
4228 this.max_w(max_content_width).mx_auto()
4229 })
4230 .flex_none()
4231 .justify_between()
4232 .gap_2();
4233
4234 let toolbar_content = if use_v2_empty_toolbar {
4235 let (chevron_icon, icon_color, label_color) =
4236 if self.new_thread_menu_handle.is_deployed() {
4237 (IconName::ChevronUp, Color::Accent, Color::Accent)
4238 } else {
4239 (IconName::ChevronDown, Color::Muted, Color::Default)
4240 };
4241
4242 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
4243 Icon::from_external_svg(icon_path)
4244 .size(IconSize::Small)
4245 .color(icon_color)
4246 } else {
4247 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
4248 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
4249 };
4250
4251 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4252 .start_icon(agent_icon)
4253 .color(label_color)
4254 .end_icon(
4255 Icon::new(chevron_icon)
4256 .color(icon_color)
4257 .size(IconSize::XSmall),
4258 );
4259
4260 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4261 .trigger_with_tooltip(agent_selector_button, {
4262 move |_window, cx| {
4263 Tooltip::for_action_in(
4264 "New Thread…",
4265 &ToggleNewThreadMenu,
4266 &focus_handle,
4267 cx,
4268 )
4269 }
4270 })
4271 .menu({
4272 let builder = new_thread_menu_builder.clone();
4273 move |window, cx| builder(window, cx)
4274 })
4275 .with_handle(self.new_thread_menu_handle.clone())
4276 .anchor(Corner::TopLeft)
4277 .offset(gpui::Point {
4278 x: px(1.0),
4279 y: px(1.0),
4280 });
4281
4282 base_container
4283 .child(
4284 h_flex()
4285 .size_full()
4286 .gap(DynamicSpacing::Base04.rems(cx))
4287 .pl(DynamicSpacing::Base04.rems(cx))
4288 .child(agent_selector_menu)
4289 .when(
4290 has_visible_worktrees && self.project_has_git_repository(cx),
4291 |this| this.child(self.render_start_thread_in_selector(cx)),
4292 )
4293 .when(
4294 matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
4295 |this| this.child(self.render_new_worktree_branch_selector(cx)),
4296 ),
4297 )
4298 .child(
4299 h_flex()
4300 .h_full()
4301 .flex_none()
4302 .gap_1()
4303 .pl_1()
4304 .pr_1()
4305 .child(full_screen_button)
4306 .child(self.render_panel_options_menu(window, cx)),
4307 )
4308 .into_any_element()
4309 } else {
4310 let new_thread_menu = PopoverMenu::new("new_thread_menu")
4311 .trigger_with_tooltip(
4312 IconButton::new("new_thread_menu_btn", IconName::Plus)
4313 .icon_size(IconSize::Small),
4314 {
4315 move |_window, cx| {
4316 Tooltip::for_action_in(
4317 "New Thread\u{2026}",
4318 &ToggleNewThreadMenu,
4319 &focus_handle,
4320 cx,
4321 )
4322 }
4323 },
4324 )
4325 .anchor(Corner::TopRight)
4326 .with_handle(self.new_thread_menu_handle.clone())
4327 .menu(move |window, cx| new_thread_menu_builder(window, cx));
4328
4329 base_container
4330 .child(
4331 h_flex()
4332 .size_full()
4333 .gap(DynamicSpacing::Base04.rems(cx))
4334 .pl(DynamicSpacing::Base04.rems(cx))
4335 .child(match &self.active_view {
4336 ActiveView::History { .. } | ActiveView::Configuration => {
4337 self.render_toolbar_back_button(cx).into_any_element()
4338 }
4339 _ => selected_agent.into_any_element(),
4340 })
4341 .child(self.render_title_view(window, cx)),
4342 )
4343 .child(
4344 h_flex()
4345 .h_full()
4346 .flex_none()
4347 .gap_1()
4348 .pl_1()
4349 .pr_1()
4350 .child(new_thread_menu)
4351 .child(full_screen_button)
4352 .child(self.render_panel_options_menu(window, cx)),
4353 )
4354 .into_any_element()
4355 };
4356
4357 h_flex()
4358 .id("agent-panel-toolbar")
4359 .h(Tab::container_height(cx))
4360 .flex_shrink_0()
4361 .max_w_full()
4362 .bg(cx.theme().colors().tab_bar_background)
4363 .border_b_1()
4364 .border_color(cx.theme().colors().border)
4365 .child(toolbar_content)
4366 }
4367
4368 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4369 let (view_id, status) = self.worktree_creation_status.as_ref()?;
4370 let active_view_id = self.active_conversation_view().map(|v| v.entity_id());
4371 if active_view_id != Some(*view_id) {
4372 return None;
4373 }
4374 match status {
4375 WorktreeCreationStatus::Creating => Some(
4376 h_flex()
4377 .absolute()
4378 .bottom_12()
4379 .w_full()
4380 .p_2()
4381 .gap_1()
4382 .justify_center()
4383 .bg(cx.theme().colors().editor_background)
4384 .child(
4385 Icon::new(IconName::LoadCircle)
4386 .size(IconSize::Small)
4387 .color(Color::Muted)
4388 .with_rotate_animation(3),
4389 )
4390 .child(
4391 Label::new("Creating Worktree…")
4392 .color(Color::Muted)
4393 .size(LabelSize::Small),
4394 )
4395 .into_any_element(),
4396 ),
4397 WorktreeCreationStatus::Error(message) => Some(
4398 Callout::new()
4399 .icon(IconName::XCircleFilled)
4400 .severity(Severity::Error)
4401 .title("Worktree Creation Error")
4402 .description(message.clone())
4403 .border_position(ui::BorderPosition::Bottom)
4404 .dismiss_action(
4405 IconButton::new("dismiss-worktree-error", IconName::Close)
4406 .icon_size(IconSize::Small)
4407 .tooltip(Tooltip::text("Dismiss"))
4408 .on_click(cx.listener(|this, _, _, cx| {
4409 this.worktree_creation_status = None;
4410 cx.notify();
4411 })),
4412 )
4413 .into_any_element(),
4414 ),
4415 }
4416 }
4417
4418 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4419 if TrialEndUpsell::dismissed(cx) {
4420 return false;
4421 }
4422
4423 match &self.active_view {
4424 ActiveView::AgentThread { .. } => {
4425 if LanguageModelRegistry::global(cx)
4426 .read(cx)
4427 .default_model()
4428 .is_some_and(|model| {
4429 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4430 })
4431 {
4432 return false;
4433 }
4434 }
4435 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4436 return false;
4437 }
4438 }
4439
4440 let plan = self.user_store.read(cx).plan();
4441 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4442
4443 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4444 }
4445
4446 fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
4447 // We only want to show this for existing users: those who
4448 // have used the agent panel before the sidebar was introduced.
4449 // We can infer that state by users having seen the onboarding
4450 // at one point, but not the agent layout onboarding.
4451
4452 let has_messages = self.active_thread_has_messages(cx);
4453 let is_dismissed = self
4454 .agent_layout_onboarding_dismissed
4455 .load(Ordering::Acquire);
4456
4457 if is_dismissed || has_messages {
4458 return false;
4459 }
4460
4461 match &self.active_view {
4462 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4463 false
4464 }
4465 ActiveView::AgentThread { .. } => {
4466 let existing_user = self
4467 .new_user_onboarding_upsell_dismissed
4468 .load(Ordering::Acquire);
4469 existing_user
4470 }
4471 }
4472 }
4473
4474 fn render_agent_layout_onboarding(
4475 &self,
4476 _window: &mut Window,
4477 cx: &mut Context<Self>,
4478 ) -> Option<impl IntoElement> {
4479 if !self.should_render_agent_layout_onboarding(cx) {
4480 return None;
4481 }
4482
4483 Some(div().child(self.agent_layout_onboarding.clone()))
4484 }
4485
4486 fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
4487 self.agent_layout_onboarding_dismissed
4488 .store(true, Ordering::Release);
4489 AgentLayoutOnboarding::set_dismissed(true, cx);
4490 cx.notify();
4491 }
4492
4493 fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
4494 self.new_user_onboarding_upsell_dismissed
4495 .store(true, Ordering::Release);
4496 OnboardingUpsell::set_dismissed(true, cx);
4497 self.dismiss_agent_layout_onboarding(cx);
4498 cx.notify();
4499 }
4500
4501 fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
4502 if self
4503 .new_user_onboarding_upsell_dismissed
4504 .load(Ordering::Acquire)
4505 {
4506 return false;
4507 }
4508
4509 let user_store = self.user_store.read(cx);
4510
4511 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4512 && user_store
4513 .subscription_period()
4514 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4515 .is_some_and(|date| date < chrono::Utc::now())
4516 {
4517 if !self
4518 .new_user_onboarding_upsell_dismissed
4519 .load(Ordering::Acquire)
4520 {
4521 self.dismiss_ai_onboarding(cx);
4522 }
4523 return false;
4524 }
4525
4526 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4527 .visible_providers()
4528 .iter()
4529 .any(|provider| {
4530 provider.is_authenticated(cx)
4531 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4532 });
4533
4534 match &self.active_view {
4535 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4536 false
4537 }
4538 ActiveView::AgentThread {
4539 conversation_view, ..
4540 } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
4541 ActiveView::AgentThread { conversation_view } => {
4542 let history_is_empty = conversation_view
4543 .read(cx)
4544 .history()
4545 .is_none_or(|h| h.read(cx).is_empty());
4546 history_is_empty || !has_configured_non_zed_providers
4547 }
4548 }
4549 }
4550
4551 fn render_new_user_onboarding(
4552 &mut self,
4553 _window: &mut Window,
4554 cx: &mut Context<Self>,
4555 ) -> Option<impl IntoElement> {
4556 if !self.should_render_new_user_onboarding(cx) {
4557 return None;
4558 }
4559
4560 Some(
4561 div()
4562 .bg(cx.theme().colors().editor_background)
4563 .child(self.new_user_onboarding.clone()),
4564 )
4565 }
4566
4567 fn render_trial_end_upsell(
4568 &self,
4569 _window: &mut Window,
4570 cx: &mut Context<Self>,
4571 ) -> Option<impl IntoElement> {
4572 if !self.should_render_trial_end_upsell(cx) {
4573 return None;
4574 }
4575
4576 Some(
4577 v_flex()
4578 .absolute()
4579 .inset_0()
4580 .size_full()
4581 .bg(cx.theme().colors().panel_background)
4582 .opacity(0.85)
4583 .block_mouse_except_scroll()
4584 .child(EndTrialUpsell::new(Arc::new({
4585 let this = cx.entity();
4586 move |_, cx| {
4587 this.update(cx, |_this, cx| {
4588 TrialEndUpsell::set_dismissed(true, cx);
4589 cx.notify();
4590 });
4591 }
4592 }))),
4593 )
4594 }
4595
4596 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4597 let is_local = self.project.read(cx).is_local();
4598 div()
4599 .invisible()
4600 .absolute()
4601 .top_0()
4602 .right_0()
4603 .bottom_0()
4604 .left_0()
4605 .bg(cx.theme().colors().drop_target_background)
4606 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4607 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4608 .when(is_local, |this| {
4609 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4610 })
4611 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4612 let item = tab.pane.read(cx).item_for_index(tab.ix);
4613 let project_paths = item
4614 .and_then(|item| item.project_path(cx))
4615 .into_iter()
4616 .collect::<Vec<_>>();
4617 this.handle_drop(project_paths, vec![], window, cx);
4618 }))
4619 .on_drop(
4620 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4621 let project_paths = selection
4622 .items()
4623 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4624 .collect::<Vec<_>>();
4625 this.handle_drop(project_paths, vec![], window, cx);
4626 }),
4627 )
4628 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4629 let tasks = paths
4630 .paths()
4631 .iter()
4632 .map(|path| {
4633 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4634 })
4635 .collect::<Vec<_>>();
4636 cx.spawn_in(window, async move |this, cx| {
4637 let mut paths = vec![];
4638 let mut added_worktrees = vec![];
4639 let opened_paths = futures::future::join_all(tasks).await;
4640 for entry in opened_paths {
4641 if let Some((worktree, project_path)) = entry.log_err() {
4642 added_worktrees.push(worktree);
4643 paths.push(project_path);
4644 }
4645 }
4646 this.update_in(cx, |this, window, cx| {
4647 this.handle_drop(paths, added_worktrees, window, cx);
4648 })
4649 .ok();
4650 })
4651 .detach();
4652 }))
4653 }
4654
4655 fn handle_drop(
4656 &mut self,
4657 paths: Vec<ProjectPath>,
4658 added_worktrees: Vec<Entity<Worktree>>,
4659 window: &mut Window,
4660 cx: &mut Context<Self>,
4661 ) {
4662 match &self.active_view {
4663 ActiveView::AgentThread { conversation_view } => {
4664 conversation_view.update(cx, |conversation_view, cx| {
4665 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4666 });
4667 }
4668 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4669 }
4670 }
4671
4672 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4673 if !self.show_trust_workspace_message {
4674 return None;
4675 }
4676
4677 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4678
4679 Some(
4680 Callout::new()
4681 .icon(IconName::Warning)
4682 .severity(Severity::Warning)
4683 .border_position(ui::BorderPosition::Bottom)
4684 .title("You're in Restricted Mode")
4685 .description(description)
4686 .actions_slot(
4687 Button::new("open-trust-modal", "Configure Project Trust")
4688 .label_size(LabelSize::Small)
4689 .style(ButtonStyle::Outlined)
4690 .on_click({
4691 cx.listener(move |this, _, window, cx| {
4692 this.workspace
4693 .update(cx, |workspace, cx| {
4694 workspace
4695 .show_worktree_trust_security_modal(true, window, cx)
4696 })
4697 .log_err();
4698 })
4699 }),
4700 ),
4701 )
4702 }
4703
4704 fn key_context(&self) -> KeyContext {
4705 let mut key_context = KeyContext::new_with_defaults();
4706 key_context.add("AgentPanel");
4707 match &self.active_view {
4708 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4709 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4710 }
4711 key_context
4712 }
4713}
4714
4715impl Render for AgentPanel {
4716 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4717 // WARNING: Changes to this element hierarchy can have
4718 // non-obvious implications to the layout of children.
4719 //
4720 // If you need to change it, please confirm:
4721 // - The message editor expands (cmd-option-esc) correctly
4722 // - When expanded, the buttons at the bottom of the panel are displayed correctly
4723 // - Font size works as expected and can be changed with cmd-+/cmd-
4724 // - Scrolling in all views works as expected
4725 // - Files can be dropped into the panel
4726 let content = v_flex()
4727 .relative()
4728 .size_full()
4729 .justify_between()
4730 .key_context(self.key_context())
4731 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4732 this.new_thread(action, window, cx);
4733 }))
4734 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4735 this.open_history(window, cx);
4736 }))
4737 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4738 this.open_configuration(window, cx);
4739 }))
4740 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4741 .on_action(cx.listener(Self::deploy_rules_library))
4742 .on_action(cx.listener(Self::go_back))
4743 .on_action(cx.listener(Self::toggle_navigation_menu))
4744 .on_action(cx.listener(Self::toggle_options_menu))
4745 .on_action(cx.listener(Self::increase_font_size))
4746 .on_action(cx.listener(Self::decrease_font_size))
4747 .on_action(cx.listener(Self::reset_font_size))
4748 .on_action(cx.listener(Self::toggle_zoom))
4749 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4750 if let Some(conversation_view) = this.active_conversation_view() {
4751 conversation_view.update(cx, |conversation_view, cx| {
4752 conversation_view.reauthenticate(window, cx)
4753 })
4754 }
4755 }))
4756 .child(self.render_toolbar(window, cx))
4757 .children(self.render_workspace_trust_message(cx))
4758 .children(self.render_new_user_onboarding(window, cx))
4759 .children(self.render_agent_layout_onboarding(window, cx))
4760 .map(|parent| match &self.active_view {
4761 ActiveView::Uninitialized => parent,
4762 ActiveView::AgentThread {
4763 conversation_view, ..
4764 } => parent
4765 .child(conversation_view.clone())
4766 .child(self.render_drag_target(cx)),
4767 ActiveView::History { view } => parent.child(view.clone()),
4768 ActiveView::Configuration => parent.children(self.configuration.clone()),
4769 })
4770 .children(self.render_worktree_creation_status(cx))
4771 .children(self.render_trial_end_upsell(window, cx));
4772
4773 match self.active_view.which_font_size_used() {
4774 WhichFontSize::AgentFont => {
4775 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4776 .size_full()
4777 .child(content)
4778 .into_any()
4779 }
4780 _ => content.into_any(),
4781 }
4782 }
4783}
4784
4785struct PromptLibraryInlineAssist {
4786 workspace: WeakEntity<Workspace>,
4787}
4788
4789impl PromptLibraryInlineAssist {
4790 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4791 Self { workspace }
4792 }
4793}
4794
4795impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4796 fn assist(
4797 &self,
4798 prompt_editor: &Entity<Editor>,
4799 initial_prompt: Option<String>,
4800 window: &mut Window,
4801 cx: &mut Context<RulesLibrary>,
4802 ) {
4803 InlineAssistant::update_global(cx, |assistant, cx| {
4804 let Some(workspace) = self.workspace.upgrade() else {
4805 return;
4806 };
4807 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4808 return;
4809 };
4810 let history = panel
4811 .read(cx)
4812 .connection_store()
4813 .read(cx)
4814 .entry(&crate::Agent::NativeAgent)
4815 .and_then(|s| s.read(cx).history())
4816 .map(|h| h.downgrade());
4817 let project = workspace.read(cx).project().downgrade();
4818 let panel = panel.read(cx);
4819 let thread_store = panel.thread_store().clone();
4820 assistant.assist(
4821 prompt_editor,
4822 self.workspace.clone(),
4823 project,
4824 thread_store,
4825 None,
4826 history,
4827 initial_prompt,
4828 window,
4829 cx,
4830 );
4831 })
4832 }
4833
4834 fn focus_agent_panel(
4835 &self,
4836 workspace: &mut Workspace,
4837 window: &mut Window,
4838 cx: &mut Context<Workspace>,
4839 ) -> bool {
4840 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4841 }
4842}
4843
4844struct OnboardingUpsell;
4845
4846impl Dismissable for OnboardingUpsell {
4847 const KEY: &'static str = "dismissed-trial-upsell";
4848}
4849
4850struct AgentLayoutOnboarding;
4851
4852impl Dismissable for AgentLayoutOnboarding {
4853 const KEY: &'static str = "dismissed-agent-layout-onboarding";
4854}
4855
4856struct TrialEndUpsell;
4857
4858impl Dismissable for TrialEndUpsell {
4859 const KEY: &'static str = "dismissed-trial-end-upsell";
4860}
4861
4862/// Test-only helper methods
4863#[cfg(any(test, feature = "test-support"))]
4864impl AgentPanel {
4865 pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4866 Self::new(workspace, None, window, cx)
4867 }
4868
4869 /// Opens an external thread using an arbitrary AgentServer.
4870 ///
4871 /// This is a test-only helper that allows visual tests and integration tests
4872 /// to inject a stub server without modifying production code paths.
4873 /// Not compiled into production builds.
4874 pub fn open_external_thread_with_server(
4875 &mut self,
4876 server: Rc<dyn AgentServer>,
4877 window: &mut Window,
4878 cx: &mut Context<Self>,
4879 ) {
4880 let workspace = self.workspace.clone();
4881 let project = self.project.clone();
4882
4883 let ext_agent = Agent::Custom {
4884 id: server.agent_id(),
4885 };
4886
4887 let conversation_view = self.create_agent_thread(
4888 server, None, None, None, None, workspace, project, ext_agent, window, cx,
4889 );
4890 self.set_active_view(
4891 ActiveView::AgentThread { conversation_view },
4892 true,
4893 window,
4894 cx,
4895 );
4896 }
4897
4898 /// Returns the currently active thread view, if any.
4899 ///
4900 /// This is a test-only accessor that exposes the private `active_thread_view()`
4901 /// method for test assertions. Not compiled into production builds.
4902 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4903 self.active_conversation_view()
4904 }
4905
4906 /// Sets the start_thread_in value directly, bypassing validation.
4907 ///
4908 /// This is a test-only helper for visual tests that need to show specific
4909 /// start_thread_in states without requiring a real git repository.
4910 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4911 self.start_thread_in = target;
4912 cx.notify();
4913 }
4914
4915 /// Returns the current worktree creation status.
4916 ///
4917 /// This is a test-only helper for visual tests.
4918 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4919 self.worktree_creation_status.as_ref().map(|(_, s)| s)
4920 }
4921
4922 /// Sets the worktree creation status directly, associating it with the
4923 /// currently active conversation view.
4924 ///
4925 /// This is a test-only helper for visual tests that need to show the
4926 /// "Creating worktree…" spinner or error banners.
4927 pub fn set_worktree_creation_status_for_tests(
4928 &mut self,
4929 status: Option<WorktreeCreationStatus>,
4930 cx: &mut Context<Self>,
4931 ) {
4932 self.worktree_creation_status = status.map(|s| {
4933 let view_id = self
4934 .active_conversation_view()
4935 .map(|v| v.entity_id())
4936 .unwrap_or_else(|| EntityId::from(0u64));
4937 (view_id, s)
4938 });
4939 cx.notify();
4940 }
4941
4942 /// Opens the history view.
4943 ///
4944 /// This is a test-only helper that exposes the private `open_history()`
4945 /// method for visual tests.
4946 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4947 self.open_history(window, cx);
4948 }
4949
4950 /// Opens the start_thread_in selector popover menu.
4951 ///
4952 /// This is a test-only helper for visual tests.
4953 pub fn open_start_thread_in_menu_for_tests(
4954 &mut self,
4955 window: &mut Window,
4956 cx: &mut Context<Self>,
4957 ) {
4958 self.start_thread_in_menu_handle.show(window, cx);
4959 }
4960
4961 /// Dismisses the start_thread_in dropdown menu.
4962 ///
4963 /// This is a test-only helper for visual tests.
4964 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4965 self.start_thread_in_menu_handle.hide(cx);
4966 }
4967}
4968
4969#[cfg(test)]
4970mod tests {
4971 use super::*;
4972 use crate::conversation_view::tests::{StubAgentServer, init_test};
4973 use crate::test_support::{
4974 active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4975 send_message,
4976 };
4977 use acp_thread::{StubAgentConnection, ThreadStatus};
4978 use agent_servers::CODEX_ID;
4979 use feature_flags::FeatureFlagAppExt;
4980 use fs::FakeFs;
4981 use gpui::{TestAppContext, VisualTestContext};
4982 use project::Project;
4983 use serde_json::json;
4984 use std::path::Path;
4985 use std::time::Instant;
4986 use workspace::MultiWorkspace;
4987
4988 #[gpui::test]
4989 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4990 init_test(cx);
4991 cx.update(|cx| {
4992 agent::ThreadStore::init_global(cx);
4993 language_model::LanguageModelRegistry::test(cx);
4994 });
4995
4996 // Create a MultiWorkspace window with two workspaces.
4997 let fs = FakeFs::new(cx.executor());
4998 let project_a = Project::test(fs.clone(), [], cx).await;
4999 let project_b = Project::test(fs, [], cx).await;
5000
5001 let multi_workspace =
5002 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5003
5004 let workspace_a = multi_workspace
5005 .read_with(cx, |multi_workspace, _cx| {
5006 multi_workspace.workspace().clone()
5007 })
5008 .unwrap();
5009
5010 let workspace_b = multi_workspace
5011 .update(cx, |multi_workspace, window, cx| {
5012 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
5013 })
5014 .unwrap();
5015
5016 workspace_a.update(cx, |workspace, _cx| {
5017 workspace.set_random_database_id();
5018 });
5019 workspace_b.update(cx, |workspace, _cx| {
5020 workspace.set_random_database_id();
5021 });
5022
5023 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5024
5025 // Set up workspace A: with an active thread.
5026 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
5027 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5028 });
5029
5030 panel_a.update_in(cx, |panel, window, cx| {
5031 panel.open_external_thread_with_server(
5032 Rc::new(StubAgentServer::default_response()),
5033 window,
5034 cx,
5035 );
5036 });
5037
5038 cx.run_until_parked();
5039
5040 panel_a.read_with(cx, |panel, cx| {
5041 assert!(
5042 panel.active_agent_thread(cx).is_some(),
5043 "workspace A should have an active thread after connection"
5044 );
5045 });
5046
5047 send_message(&panel_a, cx);
5048
5049 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
5050
5051 // Set up workspace B: ClaudeCode, no active thread.
5052 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
5053 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5054 });
5055
5056 panel_b.update(cx, |panel, _cx| {
5057 panel.selected_agent = Agent::Custom {
5058 id: "claude-acp".into(),
5059 };
5060 });
5061
5062 // Serialize both panels.
5063 panel_a.update(cx, |panel, cx| panel.serialize(cx));
5064 panel_b.update(cx, |panel, cx| panel.serialize(cx));
5065 cx.run_until_parked();
5066
5067 // Load fresh panels for each workspace and verify independent state.
5068 let async_cx = cx.update(|window, cx| window.to_async(cx));
5069 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
5070 .await
5071 .expect("panel A load should succeed");
5072 cx.run_until_parked();
5073
5074 let async_cx = cx.update(|window, cx| window.to_async(cx));
5075 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
5076 .await
5077 .expect("panel B load should succeed");
5078 cx.run_until_parked();
5079
5080 // Workspace A should restore its thread and agent type
5081 loaded_a.read_with(cx, |panel, _cx| {
5082 assert_eq!(
5083 panel.selected_agent, agent_type_a,
5084 "workspace A agent type should be restored"
5085 );
5086 assert!(
5087 panel.active_conversation_view().is_some(),
5088 "workspace A should have its active thread restored"
5089 );
5090 });
5091
5092 // Workspace B should restore its own agent type, with no thread
5093 loaded_b.read_with(cx, |panel, _cx| {
5094 assert_eq!(
5095 panel.selected_agent,
5096 Agent::Custom {
5097 id: "claude-acp".into()
5098 },
5099 "workspace B agent type should be restored"
5100 );
5101 assert!(
5102 panel.active_conversation_view().is_none(),
5103 "workspace B should have no active thread"
5104 );
5105 });
5106 }
5107
5108 #[gpui::test]
5109 async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
5110 init_test(cx);
5111 cx.update(|cx| {
5112 agent::ThreadStore::init_global(cx);
5113 language_model::LanguageModelRegistry::test(cx);
5114 });
5115
5116 let fs = FakeFs::new(cx.executor());
5117 let project = Project::test(fs, [], cx).await;
5118
5119 let multi_workspace =
5120 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5121
5122 let workspace = multi_workspace
5123 .read_with(cx, |multi_workspace, _cx| {
5124 multi_workspace.workspace().clone()
5125 })
5126 .unwrap();
5127
5128 workspace.update(cx, |workspace, _cx| {
5129 workspace.set_random_database_id();
5130 });
5131
5132 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5133
5134 let panel = workspace.update_in(cx, |workspace, window, cx| {
5135 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5136 });
5137
5138 panel.update_in(cx, |panel, window, cx| {
5139 panel.open_external_thread_with_server(
5140 Rc::new(StubAgentServer::default_response()),
5141 window,
5142 cx,
5143 );
5144 });
5145
5146 cx.run_until_parked();
5147
5148 panel.read_with(cx, |panel, cx| {
5149 assert!(
5150 panel.active_agent_thread(cx).is_some(),
5151 "should have an active thread after connection"
5152 );
5153 });
5154
5155 // Serialize without ever sending a message, so no thread metadata exists.
5156 panel.update(cx, |panel, cx| panel.serialize(cx));
5157 cx.run_until_parked();
5158
5159 let async_cx = cx.update(|window, cx| window.to_async(cx));
5160 let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
5161 .await
5162 .expect("panel load should succeed");
5163 cx.run_until_parked();
5164
5165 loaded.read_with(cx, |panel, _cx| {
5166 assert!(
5167 panel.active_conversation_view().is_none(),
5168 "thread without metadata should not be restored"
5169 );
5170 });
5171 }
5172
5173 /// Extracts the text from a Text content block, panicking if it's not Text.
5174 fn expect_text_block(block: &acp::ContentBlock) -> &str {
5175 match block {
5176 acp::ContentBlock::Text(t) => t.text.as_str(),
5177 other => panic!("expected Text block, got {:?}", other),
5178 }
5179 }
5180
5181 /// Extracts the (text_content, uri) from a Resource content block, panicking
5182 /// if it's not a TextResourceContents resource.
5183 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
5184 match block {
5185 acp::ContentBlock::Resource(r) => match &r.resource {
5186 acp::EmbeddedResourceResource::TextResourceContents(t) => {
5187 (t.text.as_str(), t.uri.as_str())
5188 }
5189 other => panic!("expected TextResourceContents, got {:?}", other),
5190 },
5191 other => panic!("expected Resource block, got {:?}", other),
5192 }
5193 }
5194
5195 #[test]
5196 fn test_build_conflict_resolution_prompt_single_conflict() {
5197 let conflicts = vec![ConflictContent {
5198 file_path: "src/main.rs".to_string(),
5199 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
5200 .to_string(),
5201 ours_branch_name: "HEAD".to_string(),
5202 theirs_branch_name: "feature".to_string(),
5203 }];
5204
5205 let blocks = build_conflict_resolution_prompt(&conflicts);
5206 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
5207 assert_eq!(
5208 blocks.len(),
5209 4,
5210 "expected 2 text + 1 resource link + 1 resource block"
5211 );
5212
5213 let intro_text = expect_text_block(&blocks[0]);
5214 assert!(
5215 intro_text.contains("Please resolve the following merge conflict in"),
5216 "prompt should include single-conflict intro text"
5217 );
5218
5219 match &blocks[1] {
5220 acp::ContentBlock::ResourceLink(link) => {
5221 assert!(
5222 link.uri.contains("file://"),
5223 "resource link URI should use file scheme"
5224 );
5225 assert!(
5226 link.uri.contains("main.rs"),
5227 "resource link URI should reference file path"
5228 );
5229 }
5230 other => panic!("expected ResourceLink block, got {:?}", other),
5231 }
5232
5233 let body_text = expect_text_block(&blocks[2]);
5234 assert!(
5235 body_text.contains("`HEAD` (ours)"),
5236 "prompt should mention ours branch"
5237 );
5238 assert!(
5239 body_text.contains("`feature` (theirs)"),
5240 "prompt should mention theirs branch"
5241 );
5242 assert!(
5243 body_text.contains("editing the file directly"),
5244 "prompt should instruct the agent to edit the file"
5245 );
5246
5247 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
5248 assert!(
5249 resource_text.contains("<<<<<<< HEAD"),
5250 "resource should contain the conflict text"
5251 );
5252 assert!(
5253 resource_uri.contains("merge-conflict"),
5254 "resource URI should use the merge-conflict scheme"
5255 );
5256 assert!(
5257 resource_uri.contains("main.rs"),
5258 "resource URI should reference the file path"
5259 );
5260 }
5261
5262 #[test]
5263 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
5264 let conflicts = vec![
5265 ConflictContent {
5266 file_path: "src/lib.rs".to_string(),
5267 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
5268 .to_string(),
5269 ours_branch_name: "main".to_string(),
5270 theirs_branch_name: "dev".to_string(),
5271 },
5272 ConflictContent {
5273 file_path: "src/lib.rs".to_string(),
5274 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5275 .to_string(),
5276 ours_branch_name: "main".to_string(),
5277 theirs_branch_name: "dev".to_string(),
5278 },
5279 ];
5280
5281 let blocks = build_conflict_resolution_prompt(&conflicts);
5282 // 1 Text instruction + 2 Resource blocks
5283 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5284
5285 let text = expect_text_block(&blocks[0]);
5286 assert!(
5287 text.contains("all 2 merge conflicts"),
5288 "prompt should mention the total count"
5289 );
5290 assert!(
5291 text.contains("`main` (ours)"),
5292 "prompt should mention ours branch"
5293 );
5294 assert!(
5295 text.contains("`dev` (theirs)"),
5296 "prompt should mention theirs branch"
5297 );
5298 // Single file, so "file" not "files"
5299 assert!(
5300 text.contains("file directly"),
5301 "single file should use singular 'file'"
5302 );
5303
5304 let (resource_a, _) = expect_resource_block(&blocks[1]);
5305 let (resource_b, _) = expect_resource_block(&blocks[2]);
5306 assert!(
5307 resource_a.contains("fn a()"),
5308 "first resource should contain first conflict"
5309 );
5310 assert!(
5311 resource_b.contains("fn b()"),
5312 "second resource should contain second conflict"
5313 );
5314 }
5315
5316 #[test]
5317 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5318 let conflicts = vec![
5319 ConflictContent {
5320 file_path: "src/a.rs".to_string(),
5321 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5322 ours_branch_name: "main".to_string(),
5323 theirs_branch_name: "dev".to_string(),
5324 },
5325 ConflictContent {
5326 file_path: "src/b.rs".to_string(),
5327 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5328 ours_branch_name: "main".to_string(),
5329 theirs_branch_name: "dev".to_string(),
5330 },
5331 ];
5332
5333 let blocks = build_conflict_resolution_prompt(&conflicts);
5334 // 1 Text instruction + 2 Resource blocks
5335 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5336
5337 let text = expect_text_block(&blocks[0]);
5338 assert!(
5339 text.contains("files directly"),
5340 "multiple files should use plural 'files'"
5341 );
5342
5343 let (_, uri_a) = expect_resource_block(&blocks[1]);
5344 let (_, uri_b) = expect_resource_block(&blocks[2]);
5345 assert!(
5346 uri_a.contains("a.rs"),
5347 "first resource URI should reference a.rs"
5348 );
5349 assert!(
5350 uri_b.contains("b.rs"),
5351 "second resource URI should reference b.rs"
5352 );
5353 }
5354
5355 #[test]
5356 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5357 let file_paths = vec![
5358 "src/main.rs".to_string(),
5359 "src/lib.rs".to_string(),
5360 "tests/integration.rs".to_string(),
5361 ];
5362
5363 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5364 // 1 instruction Text block + (ResourceLink + newline Text) per file
5365 assert_eq!(
5366 blocks.len(),
5367 1 + (file_paths.len() * 2),
5368 "expected instruction text plus resource links and separators"
5369 );
5370
5371 let text = expect_text_block(&blocks[0]);
5372 assert!(
5373 text.contains("unresolved merge conflicts"),
5374 "prompt should describe the task"
5375 );
5376 assert!(
5377 text.contains("conflict markers"),
5378 "prompt should mention conflict markers"
5379 );
5380
5381 for (index, path) in file_paths.iter().enumerate() {
5382 let link_index = 1 + (index * 2);
5383 let newline_index = link_index + 1;
5384
5385 match &blocks[link_index] {
5386 acp::ContentBlock::ResourceLink(link) => {
5387 assert!(
5388 link.uri.contains("file://"),
5389 "resource link URI should use file scheme"
5390 );
5391 assert!(
5392 link.uri.contains(path),
5393 "resource link URI should reference file path: {path}"
5394 );
5395 }
5396 other => panic!(
5397 "expected ResourceLink block at index {}, got {:?}",
5398 link_index, other
5399 ),
5400 }
5401
5402 let separator = expect_text_block(&blocks[newline_index]);
5403 assert_eq!(
5404 separator, "\n",
5405 "expected newline separator after each file"
5406 );
5407 }
5408 }
5409
5410 #[test]
5411 fn test_build_conflict_resolution_prompt_empty_conflicts() {
5412 let blocks = build_conflict_resolution_prompt(&[]);
5413 assert!(
5414 blocks.is_empty(),
5415 "empty conflicts should produce no blocks, got {} blocks",
5416 blocks.len()
5417 );
5418 }
5419
5420 #[test]
5421 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5422 let blocks = build_conflicted_files_resolution_prompt(&[]);
5423 assert!(
5424 blocks.is_empty(),
5425 "empty paths should produce no blocks, got {} blocks",
5426 blocks.len()
5427 );
5428 }
5429
5430 #[test]
5431 fn test_conflict_resource_block_structure() {
5432 let conflict = ConflictContent {
5433 file_path: "src/utils.rs".to_string(),
5434 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5435 ours_branch_name: "HEAD".to_string(),
5436 theirs_branch_name: "branch".to_string(),
5437 };
5438
5439 let block = conflict_resource_block(&conflict);
5440 let (text, uri) = expect_resource_block(&block);
5441
5442 assert_eq!(
5443 text, conflict.conflict_text,
5444 "resource text should be the raw conflict"
5445 );
5446 assert!(
5447 uri.starts_with("zed:///agent/merge-conflict"),
5448 "URI should use the zed merge-conflict scheme, got: {uri}"
5449 );
5450 assert!(uri.contains("utils.rs"), "URI should encode the file path");
5451 }
5452
5453 fn open_generating_thread_with_loadable_connection(
5454 panel: &Entity<AgentPanel>,
5455 connection: &StubAgentConnection,
5456 cx: &mut VisualTestContext,
5457 ) -> acp::SessionId {
5458 open_thread_with_custom_connection(panel, connection.clone(), cx);
5459 let session_id = active_session_id(panel, cx);
5460 send_message(panel, cx);
5461 cx.update(|_, cx| {
5462 connection.send_update(
5463 session_id.clone(),
5464 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5465 cx,
5466 );
5467 });
5468 cx.run_until_parked();
5469 session_id
5470 }
5471
5472 fn open_idle_thread_with_non_loadable_connection(
5473 panel: &Entity<AgentPanel>,
5474 connection: &StubAgentConnection,
5475 cx: &mut VisualTestContext,
5476 ) -> acp::SessionId {
5477 open_thread_with_custom_connection(panel, connection.clone(), cx);
5478 let session_id = active_session_id(panel, cx);
5479
5480 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5481 acp::ContentChunk::new("done".into()),
5482 )]);
5483 send_message(panel, cx);
5484
5485 session_id
5486 }
5487
5488 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5489 init_test(cx);
5490 cx.update(|cx| {
5491 agent::ThreadStore::init_global(cx);
5492 language_model::LanguageModelRegistry::test(cx);
5493 });
5494
5495 let fs = FakeFs::new(cx.executor());
5496 let project = Project::test(fs.clone(), [], cx).await;
5497
5498 let multi_workspace =
5499 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5500
5501 let workspace = multi_workspace
5502 .read_with(cx, |mw, _cx| mw.workspace().clone())
5503 .unwrap();
5504
5505 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5506
5507 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5508 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5509 });
5510
5511 (panel, cx)
5512 }
5513
5514 #[gpui::test]
5515 async fn test_empty_draft_thread_not_retained_when_navigating_away(cx: &mut TestAppContext) {
5516 let (panel, mut cx) = setup_panel(cx).await;
5517
5518 let connection_a = StubAgentConnection::new();
5519 open_thread_with_connection(&panel, connection_a, &mut cx);
5520 let session_id_a = active_session_id(&panel, &cx);
5521
5522 panel.read_with(&cx, |panel, cx| {
5523 let thread = panel.active_agent_thread(cx).unwrap();
5524 assert!(
5525 thread.read(cx).entries().is_empty(),
5526 "newly opened draft thread should have no entries"
5527 );
5528 assert!(panel.background_threads.is_empty());
5529 });
5530
5531 let connection_b = StubAgentConnection::new();
5532 open_thread_with_connection(&panel, connection_b, &mut cx);
5533
5534 panel.read_with(&cx, |panel, _cx| {
5535 assert!(
5536 panel.background_threads.is_empty(),
5537 "empty draft thread should not be retained in background_threads"
5538 );
5539 assert!(
5540 !panel.background_threads.contains_key(&session_id_a),
5541 "empty draft thread should not be keyed in background_threads"
5542 );
5543 });
5544 }
5545
5546 #[gpui::test]
5547 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5548 let (panel, mut cx) = setup_panel(cx).await;
5549
5550 let connection_a = StubAgentConnection::new();
5551 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5552 send_message(&panel, &mut cx);
5553
5554 let session_id_a = active_session_id(&panel, &cx);
5555
5556 // Send a chunk to keep thread A generating (don't end the turn).
5557 cx.update(|_, cx| {
5558 connection_a.send_update(
5559 session_id_a.clone(),
5560 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5561 cx,
5562 );
5563 });
5564 cx.run_until_parked();
5565
5566 // Verify thread A is generating.
5567 panel.read_with(&cx, |panel, cx| {
5568 let thread = panel.active_agent_thread(cx).unwrap();
5569 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5570 assert!(panel.background_threads.is_empty());
5571 });
5572
5573 // Open a new thread B — thread A should be retained in background.
5574 let connection_b = StubAgentConnection::new();
5575 open_thread_with_connection(&panel, connection_b, &mut cx);
5576
5577 panel.read_with(&cx, |panel, _cx| {
5578 assert_eq!(
5579 panel.background_threads.len(),
5580 1,
5581 "Running thread A should be retained in background_views"
5582 );
5583 assert!(
5584 panel.background_threads.contains_key(&session_id_a),
5585 "Background view should be keyed by thread A's session ID"
5586 );
5587 });
5588 }
5589
5590 #[gpui::test]
5591 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5592 let (panel, mut cx) = setup_panel(cx).await;
5593
5594 let connection_a = StubAgentConnection::new();
5595 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5596 acp::ContentChunk::new("Response".into()),
5597 )]);
5598 open_thread_with_connection(&panel, connection_a, &mut cx);
5599 send_message(&panel, &mut cx);
5600
5601 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5602 panel.active_conversation_view().unwrap().downgrade()
5603 });
5604 let session_id_a = active_session_id(&panel, &cx);
5605
5606 // Thread A should be idle (auto-completed via set_next_prompt_updates).
5607 panel.read_with(&cx, |panel, cx| {
5608 let thread = panel.active_agent_thread(cx).unwrap();
5609 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5610 });
5611
5612 // Open a new thread B — thread A should be retained because it is not loadable.
5613 let connection_b = StubAgentConnection::new();
5614 open_thread_with_connection(&panel, connection_b, &mut cx);
5615
5616 panel.read_with(&cx, |panel, _cx| {
5617 assert_eq!(
5618 panel.background_threads.len(),
5619 1,
5620 "Idle non-loadable thread A should be retained in background_views"
5621 );
5622 assert!(
5623 panel.background_threads.contains_key(&session_id_a),
5624 "Background view should be keyed by thread A's session ID"
5625 );
5626 });
5627
5628 assert!(
5629 weak_view_a.upgrade().is_some(),
5630 "Idle non-loadable ConnectionView should still be retained"
5631 );
5632 }
5633
5634 #[gpui::test]
5635 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5636 let (panel, mut cx) = setup_panel(cx).await;
5637
5638 let connection_a = StubAgentConnection::new();
5639 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5640 send_message(&panel, &mut cx);
5641
5642 let session_id_a = active_session_id(&panel, &cx);
5643
5644 // Keep thread A generating.
5645 cx.update(|_, cx| {
5646 connection_a.send_update(
5647 session_id_a.clone(),
5648 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5649 cx,
5650 );
5651 });
5652 cx.run_until_parked();
5653
5654 // Open thread B — thread A goes to background.
5655 let connection_b = StubAgentConnection::new();
5656 open_thread_with_connection(&panel, connection_b, &mut cx);
5657 send_message(&panel, &mut cx);
5658
5659 let session_id_b = active_session_id(&panel, &cx);
5660
5661 panel.read_with(&cx, |panel, _cx| {
5662 assert_eq!(panel.background_threads.len(), 1);
5663 assert!(panel.background_threads.contains_key(&session_id_a));
5664 });
5665
5666 // Load thread A back via load_agent_thread — should promote from background.
5667 panel.update_in(&mut cx, |panel, window, cx| {
5668 panel.load_agent_thread(
5669 panel.selected_agent().expect("selected agent must be set"),
5670 session_id_a.clone(),
5671 None,
5672 None,
5673 true,
5674 window,
5675 cx,
5676 );
5677 });
5678
5679 // Thread A should now be the active view, promoted from background.
5680 let active_session = active_session_id(&panel, &cx);
5681 assert_eq!(
5682 active_session, session_id_a,
5683 "Thread A should be the active thread after promotion"
5684 );
5685
5686 panel.read_with(&cx, |panel, _cx| {
5687 assert!(
5688 !panel.background_threads.contains_key(&session_id_a),
5689 "Promoted thread A should no longer be in background_views"
5690 );
5691 assert!(
5692 panel.background_threads.contains_key(&session_id_b),
5693 "Thread B (idle, non-loadable) should remain retained in background_views"
5694 );
5695 });
5696 }
5697
5698 #[gpui::test]
5699 async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
5700 cx: &mut TestAppContext,
5701 ) {
5702 let (panel, mut cx) = setup_panel(cx).await;
5703 let connection = StubAgentConnection::new()
5704 .with_supports_load_session(true)
5705 .with_agent_id("loadable-stub".into())
5706 .with_telemetry_id("loadable-stub".into());
5707 let mut session_ids = Vec::new();
5708
5709 for _ in 0..7 {
5710 session_ids.push(open_generating_thread_with_loadable_connection(
5711 &panel,
5712 &connection,
5713 &mut cx,
5714 ));
5715 }
5716
5717 let base_time = Instant::now();
5718
5719 for session_id in session_ids.iter().take(6) {
5720 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5721 }
5722 cx.run_until_parked();
5723
5724 panel.update(&mut cx, |panel, cx| {
5725 for (index, session_id) in session_ids.iter().take(6).enumerate() {
5726 let conversation_view = panel
5727 .background_threads
5728 .get(session_id)
5729 .expect("background thread should exist")
5730 .clone();
5731 conversation_view.update(cx, |view, cx| {
5732 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5733 });
5734 }
5735 panel.cleanup_background_threads(cx);
5736 });
5737
5738 panel.read_with(&cx, |panel, _cx| {
5739 assert_eq!(
5740 panel.background_threads.len(),
5741 5,
5742 "cleanup should keep at most five idle loadable background threads"
5743 );
5744 assert!(
5745 !panel.background_threads.contains_key(&session_ids[0]),
5746 "oldest idle loadable background thread should be removed"
5747 );
5748 for session_id in &session_ids[1..6] {
5749 assert!(
5750 panel.background_threads.contains_key(session_id),
5751 "more recent idle loadable background threads should be retained"
5752 );
5753 }
5754 assert!(
5755 !panel.background_threads.contains_key(&session_ids[6]),
5756 "the active thread should not also be stored as a background thread"
5757 );
5758 });
5759 }
5760
5761 #[gpui::test]
5762 async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
5763 cx: &mut TestAppContext,
5764 ) {
5765 let (panel, mut cx) = setup_panel(cx).await;
5766
5767 let non_loadable_connection = StubAgentConnection::new();
5768 let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
5769 &panel,
5770 &non_loadable_connection,
5771 &mut cx,
5772 );
5773
5774 let loadable_connection = StubAgentConnection::new()
5775 .with_supports_load_session(true)
5776 .with_agent_id("loadable-stub".into())
5777 .with_telemetry_id("loadable-stub".into());
5778 let mut loadable_session_ids = Vec::new();
5779
5780 for _ in 0..7 {
5781 loadable_session_ids.push(open_generating_thread_with_loadable_connection(
5782 &panel,
5783 &loadable_connection,
5784 &mut cx,
5785 ));
5786 }
5787
5788 let base_time = Instant::now();
5789
5790 for session_id in loadable_session_ids.iter().take(6) {
5791 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5792 }
5793 cx.run_until_parked();
5794
5795 panel.update(&mut cx, |panel, cx| {
5796 for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
5797 let conversation_view = panel
5798 .background_threads
5799 .get(session_id)
5800 .expect("background thread should exist")
5801 .clone();
5802 conversation_view.update(cx, |view, cx| {
5803 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5804 });
5805 }
5806 panel.cleanup_background_threads(cx);
5807 });
5808
5809 panel.read_with(&cx, |panel, _cx| {
5810 assert_eq!(
5811 panel.background_threads.len(),
5812 6,
5813 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
5814 );
5815 assert!(
5816 panel
5817 .background_threads
5818 .contains_key(&non_loadable_session_id),
5819 "idle non-loadable background threads should not be cleanup candidates"
5820 );
5821 assert!(
5822 !panel
5823 .background_threads
5824 .contains_key(&loadable_session_ids[0]),
5825 "oldest idle loadable background thread should still be removed"
5826 );
5827 for session_id in &loadable_session_ids[1..6] {
5828 assert!(
5829 panel.background_threads.contains_key(session_id),
5830 "more recent idle loadable background threads should be retained"
5831 );
5832 }
5833 assert!(
5834 !panel
5835 .background_threads
5836 .contains_key(&loadable_session_ids[6]),
5837 "the active loadable thread should not also be stored as a background thread"
5838 );
5839 });
5840 }
5841
5842 #[gpui::test]
5843 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5844 init_test(cx);
5845 cx.update(|cx| {
5846 agent::ThreadStore::init_global(cx);
5847 language_model::LanguageModelRegistry::test(cx);
5848 });
5849
5850 let fs = FakeFs::new(cx.executor());
5851 fs.insert_tree(
5852 "/project",
5853 json!({
5854 ".git": {},
5855 "src": {
5856 "main.rs": "fn main() {}"
5857 }
5858 }),
5859 )
5860 .await;
5861 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5862
5863 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5864
5865 let multi_workspace =
5866 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5867
5868 let workspace = multi_workspace
5869 .read_with(cx, |multi_workspace, _cx| {
5870 multi_workspace.workspace().clone()
5871 })
5872 .unwrap();
5873
5874 workspace.update(cx, |workspace, _cx| {
5875 workspace.set_random_database_id();
5876 });
5877
5878 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5879
5880 // Wait for the project to discover the git repository.
5881 cx.run_until_parked();
5882
5883 let panel = workspace.update_in(cx, |workspace, window, cx| {
5884 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5885 workspace.add_panel(panel.clone(), window, cx);
5886 panel
5887 });
5888
5889 cx.run_until_parked();
5890
5891 // Default thread target should be LocalProject.
5892 panel.read_with(cx, |panel, _cx| {
5893 assert_eq!(
5894 *panel.start_thread_in(),
5895 StartThreadIn::LocalProject,
5896 "default thread target should be LocalProject"
5897 );
5898 });
5899
5900 // Start a new thread with the default LocalProject target.
5901 // Use StubAgentServer so the thread connects immediately in tests.
5902 panel.update_in(cx, |panel, window, cx| {
5903 panel.open_external_thread_with_server(
5904 Rc::new(StubAgentServer::default_response()),
5905 window,
5906 cx,
5907 );
5908 });
5909
5910 cx.run_until_parked();
5911
5912 // MultiWorkspace should still have exactly one workspace (no worktree created).
5913 multi_workspace
5914 .read_with(cx, |multi_workspace, _cx| {
5915 assert_eq!(
5916 multi_workspace.workspaces().count(),
5917 1,
5918 "LocalProject should not create a new workspace"
5919 );
5920 })
5921 .unwrap();
5922
5923 // The thread should be active in the panel.
5924 panel.read_with(cx, |panel, cx| {
5925 assert!(
5926 panel.active_agent_thread(cx).is_some(),
5927 "a thread should be running in the current workspace"
5928 );
5929 });
5930
5931 // The thread target should still be LocalProject (unchanged).
5932 panel.read_with(cx, |panel, _cx| {
5933 assert_eq!(
5934 *panel.start_thread_in(),
5935 StartThreadIn::LocalProject,
5936 "thread target should remain LocalProject"
5937 );
5938 });
5939
5940 // No worktree creation status should be set.
5941 panel.read_with(cx, |panel, _cx| {
5942 assert!(
5943 panel.worktree_creation_status.is_none(),
5944 "no worktree creation should have occurred"
5945 );
5946 });
5947 }
5948
5949 #[gpui::test]
5950 async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target(
5951 cx: &mut TestAppContext,
5952 ) {
5953 use git::repository::Worktree as GitWorktree;
5954
5955 init_test(cx);
5956 cx.update(|cx| {
5957 agent::ThreadStore::init_global(cx);
5958 language_model::LanguageModelRegistry::test(cx);
5959 });
5960
5961 let fs = FakeFs::new(cx.executor());
5962 fs.insert_tree(
5963 "/project",
5964 json!({
5965 ".git": {},
5966 "src": {
5967 "main.rs": "fn main() {}"
5968 }
5969 }),
5970 )
5971 .await;
5972 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5973 fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]);
5974
5975 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5976
5977 let multi_workspace =
5978 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5979
5980 let workspace = multi_workspace
5981 .read_with(cx, |multi_workspace, _cx| {
5982 multi_workspace.workspace().clone()
5983 })
5984 .unwrap();
5985
5986 workspace.update(cx, |workspace, _cx| {
5987 workspace.set_random_database_id();
5988 });
5989
5990 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5991
5992 cx.run_until_parked();
5993
5994 let panel = workspace.update_in(cx, |workspace, window, cx| {
5995 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5996 workspace.add_panel(panel.clone(), window, cx);
5997 panel
5998 });
5999
6000 cx.run_until_parked();
6001
6002 panel.update_in(cx, |panel, window, cx| {
6003 panel.set_start_thread_in(
6004 &StartThreadIn::NewWorktree {
6005 worktree_name: Some("feature worktree".to_string()),
6006 branch_target: NewWorktreeBranchTarget::CurrentBranch,
6007 },
6008 window,
6009 cx,
6010 );
6011 });
6012
6013 fs.add_linked_worktree_for_repo(
6014 Path::new("/project/.git"),
6015 true,
6016 GitWorktree {
6017 path: PathBuf::from("/linked-feature-worktree"),
6018 ref_name: Some("refs/heads/feature-worktree".into()),
6019 sha: "abcdef1".into(),
6020 is_main: false,
6021 },
6022 )
6023 .await;
6024
6025 project
6026 .update(cx, |project, cx| project.git_scans_complete(cx))
6027 .await;
6028 cx.run_until_parked();
6029
6030 panel.read_with(cx, |panel, _cx| {
6031 assert_eq!(
6032 *panel.start_thread_in(),
6033 StartThreadIn::NewWorktree {
6034 worktree_name: Some("feature worktree".to_string()),
6035 branch_target: NewWorktreeBranchTarget::CurrentBranch,
6036 },
6037 "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target",
6038 );
6039 });
6040 }
6041
6042 #[gpui::test]
6043 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
6044 init_test(cx);
6045 cx.update(|cx| {
6046 agent::ThreadStore::init_global(cx);
6047 language_model::LanguageModelRegistry::test(cx);
6048 });
6049
6050 let fs = FakeFs::new(cx.executor());
6051 fs.insert_tree(
6052 "/project",
6053 json!({
6054 ".git": {},
6055 "src": {
6056 "main.rs": "fn main() {}"
6057 }
6058 }),
6059 )
6060 .await;
6061 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6062
6063 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6064
6065 let multi_workspace =
6066 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6067
6068 let workspace = multi_workspace
6069 .read_with(cx, |multi_workspace, _cx| {
6070 multi_workspace.workspace().clone()
6071 })
6072 .unwrap();
6073
6074 workspace.update(cx, |workspace, _cx| {
6075 workspace.set_random_database_id();
6076 });
6077
6078 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6079
6080 // Wait for the project to discover the git repository.
6081 cx.run_until_parked();
6082
6083 let panel = workspace.update_in(cx, |workspace, window, cx| {
6084 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6085 workspace.add_panel(panel.clone(), window, cx);
6086 panel
6087 });
6088
6089 cx.run_until_parked();
6090
6091 // Default should be LocalProject.
6092 panel.read_with(cx, |panel, _cx| {
6093 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
6094 });
6095
6096 // Change thread target to NewWorktree.
6097 panel.update_in(cx, |panel, window, cx| {
6098 panel.set_start_thread_in(
6099 &StartThreadIn::NewWorktree {
6100 worktree_name: None,
6101 branch_target: NewWorktreeBranchTarget::default(),
6102 },
6103 window,
6104 cx,
6105 );
6106 });
6107
6108 panel.read_with(cx, |panel, _cx| {
6109 assert_eq!(
6110 *panel.start_thread_in(),
6111 StartThreadIn::NewWorktree {
6112 worktree_name: None,
6113 branch_target: NewWorktreeBranchTarget::default(),
6114 },
6115 "thread target should be NewWorktree after set_thread_target"
6116 );
6117 });
6118
6119 // Let serialization complete.
6120 cx.run_until_parked();
6121
6122 // Load a fresh panel from the serialized data.
6123 let async_cx = cx.update(|window, cx| window.to_async(cx));
6124 let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
6125 .await
6126 .expect("panel load should succeed");
6127 cx.run_until_parked();
6128
6129 loaded_panel.read_with(cx, |panel, _cx| {
6130 assert_eq!(
6131 *panel.start_thread_in(),
6132 StartThreadIn::NewWorktree {
6133 worktree_name: None,
6134 branch_target: NewWorktreeBranchTarget::default(),
6135 },
6136 "thread target should survive serialization round-trip"
6137 );
6138 });
6139 }
6140
6141 #[gpui::test]
6142 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
6143 init_test(cx);
6144
6145 let fs = FakeFs::new(cx.executor());
6146 cx.update(|cx| {
6147 agent::ThreadStore::init_global(cx);
6148 language_model::LanguageModelRegistry::test(cx);
6149 <dyn fs::Fs>::set_global(fs.clone(), cx);
6150 });
6151
6152 fs.insert_tree(
6153 "/project",
6154 json!({
6155 ".git": {},
6156 "src": {
6157 "main.rs": "fn main() {}"
6158 }
6159 }),
6160 )
6161 .await;
6162
6163 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6164
6165 let multi_workspace =
6166 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6167
6168 let workspace = multi_workspace
6169 .read_with(cx, |multi_workspace, _cx| {
6170 multi_workspace.workspace().clone()
6171 })
6172 .unwrap();
6173
6174 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6175
6176 let panel = workspace.update_in(cx, |workspace, window, cx| {
6177 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6178 workspace.add_panel(panel.clone(), window, cx);
6179 panel
6180 });
6181
6182 cx.run_until_parked();
6183
6184 // Simulate worktree creation in progress and reset to Uninitialized
6185 panel.update_in(cx, |panel, window, cx| {
6186 panel.worktree_creation_status =
6187 Some((EntityId::from(0u64), WorktreeCreationStatus::Creating));
6188 panel.active_view = ActiveView::Uninitialized;
6189 Panel::set_active(panel, true, window, cx);
6190 assert!(
6191 matches!(panel.active_view, ActiveView::Uninitialized),
6192 "set_active should not create a thread while worktree is being created"
6193 );
6194 });
6195
6196 // Clear the creation status and use open_external_thread_with_server
6197 // (which bypasses new_agent_thread) to verify the panel can transition
6198 // out of Uninitialized. We can't call set_active directly because
6199 // new_agent_thread requires full agent server infrastructure.
6200 panel.update_in(cx, |panel, window, cx| {
6201 panel.worktree_creation_status = None;
6202 panel.active_view = ActiveView::Uninitialized;
6203 panel.open_external_thread_with_server(
6204 Rc::new(StubAgentServer::default_response()),
6205 window,
6206 cx,
6207 );
6208 });
6209
6210 cx.run_until_parked();
6211
6212 panel.read_with(cx, |panel, _cx| {
6213 assert!(
6214 !matches!(panel.active_view, ActiveView::Uninitialized),
6215 "panel should transition out of Uninitialized once worktree creation is cleared"
6216 );
6217 });
6218 }
6219
6220 #[test]
6221 fn test_deserialize_agent_variants() {
6222 // PascalCase (legacy AgentType format, persisted in panel state)
6223 assert_eq!(
6224 serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
6225 Agent::NativeAgent,
6226 );
6227 assert_eq!(
6228 serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
6229 Agent::Custom {
6230 id: "my-agent".into(),
6231 },
6232 );
6233
6234 // Legacy TextThread variant deserializes to NativeAgent
6235 assert_eq!(
6236 serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
6237 Agent::NativeAgent,
6238 );
6239
6240 // snake_case (canonical format)
6241 assert_eq!(
6242 serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
6243 Agent::NativeAgent,
6244 );
6245 assert_eq!(
6246 serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
6247 Agent::Custom {
6248 id: "my-agent".into(),
6249 },
6250 );
6251
6252 // Serialization uses snake_case
6253 assert_eq!(
6254 serde_json::to_string(&Agent::NativeAgent).unwrap(),
6255 r#""native_agent""#,
6256 );
6257 assert_eq!(
6258 serde_json::to_string(&Agent::Custom {
6259 id: "my-agent".into()
6260 })
6261 .unwrap(),
6262 r#"{"custom":{"name":"my-agent"}}"#,
6263 );
6264 }
6265
6266 #[test]
6267 fn test_resolve_worktree_branch_target() {
6268 let existing_branches = HashSet::from_iter([
6269 "main".to_string(),
6270 "feature".to_string(),
6271 "origin/main".to_string(),
6272 ]);
6273
6274 let resolved = AgentPanel::resolve_worktree_branch_target(
6275 &NewWorktreeBranchTarget::CreateBranch {
6276 name: "new-branch".to_string(),
6277 from_ref: Some("main".to_string()),
6278 },
6279 &existing_branches,
6280 &HashSet::from_iter(["main".to_string()]),
6281 )
6282 .unwrap();
6283 assert_eq!(
6284 resolved,
6285 ("new-branch".to_string(), false, Some("main".to_string()))
6286 );
6287
6288 let resolved = AgentPanel::resolve_worktree_branch_target(
6289 &NewWorktreeBranchTarget::ExistingBranch {
6290 name: "feature".to_string(),
6291 },
6292 &existing_branches,
6293 &HashSet::default(),
6294 )
6295 .unwrap();
6296 assert_eq!(resolved, ("feature".to_string(), true, None));
6297
6298 let resolved = AgentPanel::resolve_worktree_branch_target(
6299 &NewWorktreeBranchTarget::ExistingBranch {
6300 name: "main".to_string(),
6301 },
6302 &existing_branches,
6303 &HashSet::from_iter(["main".to_string()]),
6304 )
6305 .unwrap();
6306 assert_eq!(resolved.1, false);
6307 assert_eq!(resolved.2, Some("main".to_string()));
6308 assert_ne!(resolved.0, "main");
6309 assert!(existing_branches.contains("main"));
6310 assert!(!existing_branches.contains(&resolved.0));
6311 }
6312
6313 #[gpui::test]
6314 async fn test_worktree_dir_name_is_random_when_using_existing_branch(cx: &mut TestAppContext) {
6315 init_test(cx);
6316
6317 let app_state = cx.update(|cx| {
6318 agent::ThreadStore::init_global(cx);
6319 language_model::LanguageModelRegistry::test(cx);
6320
6321 let app_state = workspace::AppState::test(cx);
6322 workspace::init(app_state.clone(), cx);
6323 app_state
6324 });
6325
6326 let fs = app_state.fs.as_fake();
6327 fs.insert_tree(
6328 "/project",
6329 json!({
6330 ".git": {},
6331 "src": {
6332 "main.rs": "fn main() {}"
6333 }
6334 }),
6335 )
6336 .await;
6337 // Put the main worktree on "develop" so that "main" is NOT
6338 // occupied by any worktree.
6339 fs.set_branch_name(Path::new("/project/.git"), Some("develop"));
6340 fs.insert_branches(Path::new("/project/.git"), &["main", "develop"]);
6341
6342 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6343
6344 let multi_workspace =
6345 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6346 multi_workspace
6347 .update(cx, |multi_workspace, _, cx| {
6348 multi_workspace.open_sidebar(cx);
6349 })
6350 .unwrap();
6351
6352 let workspace = multi_workspace
6353 .read_with(cx, |multi_workspace, _cx| {
6354 multi_workspace.workspace().clone()
6355 })
6356 .unwrap();
6357
6358 workspace.update(cx, |workspace, _cx| {
6359 workspace.set_random_database_id();
6360 });
6361
6362 cx.update(|cx| {
6363 cx.observe_new(
6364 |workspace: &mut Workspace,
6365 window: Option<&mut Window>,
6366 cx: &mut Context<Workspace>| {
6367 if let Some(window) = window {
6368 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6369 workspace.add_panel(panel, window, cx);
6370 }
6371 },
6372 )
6373 .detach();
6374 });
6375
6376 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6377 cx.run_until_parked();
6378
6379 let panel = workspace.update_in(cx, |workspace, window, cx| {
6380 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6381 workspace.add_panel(panel.clone(), window, cx);
6382 panel
6383 });
6384
6385 cx.run_until_parked();
6386
6387 panel.update_in(cx, |panel, window, cx| {
6388 panel.open_external_thread_with_server(
6389 Rc::new(StubAgentServer::default_response()),
6390 window,
6391 cx,
6392 );
6393 });
6394
6395 cx.run_until_parked();
6396
6397 // Select "main" as an existing branch — this should NOT make the
6398 // worktree directory named "main"; it should get a random name.
6399 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6400 "Hello from test",
6401 ))];
6402 panel.update_in(cx, |panel, window, cx| {
6403 panel.handle_worktree_requested(
6404 content,
6405 WorktreeCreationArgs::New {
6406 worktree_name: None,
6407 branch_target: NewWorktreeBranchTarget::ExistingBranch {
6408 name: "main".to_string(),
6409 },
6410 },
6411 window,
6412 cx,
6413 );
6414 });
6415
6416 cx.run_until_parked();
6417
6418 // Find the new workspace and check its worktree path.
6419 let new_worktree_path = multi_workspace
6420 .read_with(cx, |multi_workspace, cx| {
6421 let new_workspace = multi_workspace
6422 .workspaces()
6423 .find(|ws| ws.entity_id() != workspace.entity_id())
6424 .expect("a new workspace should have been created");
6425
6426 let new_project = new_workspace.read(cx).project().clone();
6427 let worktree = new_project
6428 .read(cx)
6429 .visible_worktrees(cx)
6430 .next()
6431 .expect("new workspace should have a worktree");
6432 worktree.read(cx).abs_path().to_path_buf()
6433 })
6434 .unwrap();
6435
6436 // The worktree directory path should contain a random adjective-noun
6437 // name, NOT the branch name "main".
6438 let path_str = new_worktree_path.to_string_lossy();
6439 assert!(
6440 !path_str.contains("/main/"),
6441 "worktree directory should use a random name, not the branch name. \
6442 Got path: {path_str}",
6443 );
6444 // Verify it looks like an adjective-noun pair (contains a hyphen in
6445 // the directory component above the project name).
6446 let parent = new_worktree_path
6447 .parent()
6448 .and_then(|p| p.file_name())
6449 .and_then(|n| n.to_str())
6450 .expect("should have a parent directory name");
6451 assert!(
6452 parent.contains('-'),
6453 "worktree parent directory should be an adjective-noun pair (e.g. 'swift-falcon'), \
6454 got: {parent}",
6455 );
6456 }
6457
6458 #[gpui::test]
6459 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6460 init_test(cx);
6461
6462 let app_state = cx.update(|cx| {
6463 agent::ThreadStore::init_global(cx);
6464 language_model::LanguageModelRegistry::test(cx);
6465
6466 let app_state = workspace::AppState::test(cx);
6467 workspace::init(app_state.clone(), cx);
6468 app_state
6469 });
6470
6471 let fs = app_state.fs.as_fake();
6472 fs.insert_tree(
6473 "/project",
6474 json!({
6475 ".git": {},
6476 "src": {
6477 "main.rs": "fn main() {}"
6478 }
6479 }),
6480 )
6481 .await;
6482 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6483
6484 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6485
6486 let multi_workspace =
6487 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6488 multi_workspace
6489 .update(cx, |multi_workspace, _, cx| {
6490 multi_workspace.open_sidebar(cx);
6491 })
6492 .unwrap();
6493
6494 let workspace = multi_workspace
6495 .read_with(cx, |multi_workspace, _cx| {
6496 multi_workspace.workspace().clone()
6497 })
6498 .unwrap();
6499
6500 workspace.update(cx, |workspace, _cx| {
6501 workspace.set_random_database_id();
6502 });
6503
6504 // Register a callback so new workspaces also get an AgentPanel.
6505 cx.update(|cx| {
6506 cx.observe_new(
6507 |workspace: &mut Workspace,
6508 window: Option<&mut Window>,
6509 cx: &mut Context<Workspace>| {
6510 if let Some(window) = window {
6511 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6512 workspace.add_panel(panel, window, cx);
6513 }
6514 },
6515 )
6516 .detach();
6517 });
6518
6519 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6520
6521 // Wait for the project to discover the git repository.
6522 cx.run_until_parked();
6523
6524 let panel = workspace.update_in(cx, |workspace, window, cx| {
6525 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6526 workspace.add_panel(panel.clone(), window, cx);
6527 panel
6528 });
6529
6530 cx.run_until_parked();
6531
6532 // Open a thread (needed so there's an active thread view).
6533 panel.update_in(cx, |panel, window, cx| {
6534 panel.open_external_thread_with_server(
6535 Rc::new(StubAgentServer::default_response()),
6536 window,
6537 cx,
6538 );
6539 });
6540
6541 cx.run_until_parked();
6542
6543 // Set the selected agent to Codex (a custom agent) and start_thread_in
6544 // to NewWorktree. We do this AFTER opening the thread because
6545 // open_external_thread_with_server overrides selected_agent.
6546 panel.update_in(cx, |panel, window, cx| {
6547 panel.selected_agent = Agent::Custom {
6548 id: CODEX_ID.into(),
6549 };
6550 panel.set_start_thread_in(
6551 &StartThreadIn::NewWorktree {
6552 worktree_name: None,
6553 branch_target: NewWorktreeBranchTarget::default(),
6554 },
6555 window,
6556 cx,
6557 );
6558 });
6559
6560 // Verify the panel has the Codex agent selected.
6561 panel.read_with(cx, |panel, _cx| {
6562 assert_eq!(
6563 panel.selected_agent,
6564 Agent::Custom {
6565 id: CODEX_ID.into()
6566 },
6567 );
6568 });
6569
6570 // Directly call handle_worktree_creation_requested, which is what
6571 // handle_first_send_requested does when start_thread_in == NewWorktree.
6572 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6573 "Hello from test",
6574 ))];
6575 panel.update_in(cx, |panel, window, cx| {
6576 panel.handle_worktree_requested(
6577 content,
6578 WorktreeCreationArgs::New {
6579 worktree_name: None,
6580 branch_target: NewWorktreeBranchTarget::default(),
6581 },
6582 window,
6583 cx,
6584 );
6585 });
6586
6587 // Let the async worktree creation + workspace setup complete.
6588 cx.run_until_parked();
6589
6590 panel.read_with(cx, |panel, _cx| {
6591 assert_eq!(
6592 panel.start_thread_in(),
6593 &StartThreadIn::LocalProject,
6594 "the original panel should reset start_thread_in back to the local project after creating a worktree workspace",
6595 );
6596 });
6597
6598 // Find the new workspace's AgentPanel and verify it used the Codex agent.
6599 let found_codex = multi_workspace
6600 .read_with(cx, |multi_workspace, cx| {
6601 // There should be more than one workspace now (the original + the new worktree).
6602 assert!(
6603 multi_workspace.workspaces().count() > 1,
6604 "expected a new workspace to have been created, found {}",
6605 multi_workspace.workspaces().count(),
6606 );
6607
6608 // Check the newest workspace's panel for the correct agent.
6609 let new_workspace = multi_workspace
6610 .workspaces()
6611 .find(|ws| ws.entity_id() != workspace.entity_id())
6612 .expect("should find the new workspace");
6613 let new_panel = new_workspace
6614 .read(cx)
6615 .panel::<AgentPanel>(cx)
6616 .expect("new workspace should have an AgentPanel");
6617
6618 new_panel.read(cx).selected_agent.clone()
6619 })
6620 .unwrap();
6621
6622 assert_eq!(
6623 found_codex,
6624 Agent::Custom {
6625 id: CODEX_ID.into()
6626 },
6627 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6628 );
6629 }
6630
6631 #[gpui::test]
6632 async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
6633 use crate::thread_metadata_store::ThreadMetadataStore;
6634
6635 init_test(cx);
6636 cx.update(|cx| {
6637 agent::ThreadStore::init_global(cx);
6638 language_model::LanguageModelRegistry::test(cx);
6639 });
6640
6641 // Set up a project with one worktree.
6642 let fs = FakeFs::new(cx.executor());
6643 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
6644 .await;
6645 let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
6646
6647 let multi_workspace =
6648 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6649 let workspace = multi_workspace
6650 .read_with(cx, |mw, _cx| mw.workspace().clone())
6651 .unwrap();
6652 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
6653
6654 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
6655 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6656 });
6657
6658 // Open thread A and send a message. With empty next_prompt_updates it
6659 // stays generating, so opening B will move A to background_threads.
6660 let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
6661 open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
6662 send_message(&panel, &mut cx);
6663 let session_id_a = active_session_id(&panel, &cx);
6664
6665 // Open thread C — thread A (generating) moves to background.
6666 // Thread C completes immediately (idle), then opening B moves C to background too.
6667 let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
6668 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6669 acp::ContentChunk::new("done".into()),
6670 )]);
6671 open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
6672 send_message(&panel, &mut cx);
6673 let session_id_c = active_session_id(&panel, &cx);
6674
6675 // Open thread B — thread C (idle, non-loadable) is retained in background.
6676 let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
6677 open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
6678 send_message(&panel, &mut cx);
6679 let session_id_b = active_session_id(&panel, &cx);
6680
6681 let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
6682
6683 panel.read_with(&cx, |panel, _cx| {
6684 assert!(
6685 panel.background_threads.contains_key(&session_id_a),
6686 "Thread A should be in background_threads"
6687 );
6688 assert!(
6689 panel.background_threads.contains_key(&session_id_c),
6690 "Thread C should be in background_threads"
6691 );
6692 });
6693
6694 // Verify initial work_dirs for thread B contain only /project_a.
6695 let initial_b_paths = panel.read_with(&cx, |panel, cx| {
6696 let thread = panel.active_agent_thread(cx).unwrap();
6697 thread.read(cx).work_dirs().cloned().unwrap()
6698 });
6699 assert_eq!(
6700 initial_b_paths.ordered_paths().collect::<Vec<_>>(),
6701 vec![&PathBuf::from("/project_a")],
6702 "Thread B should initially have only /project_a"
6703 );
6704
6705 // Now add a second worktree to the project.
6706 fs.insert_tree("/project_b", json!({ "other.txt": "" }))
6707 .await;
6708 let (new_tree, _) = project
6709 .update(&mut cx, |project, cx| {
6710 project.find_or_create_worktree("/project_b", true, cx)
6711 })
6712 .await
6713 .unwrap();
6714 cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
6715 .await;
6716 cx.run_until_parked();
6717
6718 // Verify thread B's (active) work_dirs now include both worktrees.
6719 let updated_b_paths = panel.read_with(&cx, |panel, cx| {
6720 let thread = panel.active_agent_thread(cx).unwrap();
6721 thread.read(cx).work_dirs().cloned().unwrap()
6722 });
6723 let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
6724 b_paths_sorted.sort();
6725 assert_eq!(
6726 b_paths_sorted,
6727 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6728 "Thread B work_dirs should include both worktrees after adding /project_b"
6729 );
6730
6731 // Verify thread A's (background) work_dirs are also updated.
6732 let updated_a_paths = panel.read_with(&cx, |panel, cx| {
6733 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6734 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6735 root_thread
6736 .read(cx)
6737 .thread
6738 .read(cx)
6739 .work_dirs()
6740 .cloned()
6741 .unwrap()
6742 });
6743 let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
6744 a_paths_sorted.sort();
6745 assert_eq!(
6746 a_paths_sorted,
6747 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6748 "Thread A work_dirs should include both worktrees after adding /project_b"
6749 );
6750
6751 // Verify thread idle C was also updated.
6752 let updated_c_paths = panel.read_with(&cx, |panel, cx| {
6753 let bg_view = panel.background_threads.get(&session_id_c).unwrap();
6754 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6755 root_thread
6756 .read(cx)
6757 .thread
6758 .read(cx)
6759 .work_dirs()
6760 .cloned()
6761 .unwrap()
6762 });
6763 let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
6764 c_paths_sorted.sort();
6765 assert_eq!(
6766 c_paths_sorted,
6767 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6768 "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
6769 );
6770
6771 // Verify the metadata store reflects the new paths for running threads only.
6772 cx.run_until_parked();
6773 for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
6774 let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
6775 let metadata = store
6776 .entry(session_id)
6777 .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
6778 metadata.folder_paths().clone()
6779 });
6780 let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
6781 sorted.sort();
6782 assert_eq!(
6783 sorted,
6784 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6785 "{label} thread metadata folder_paths should include both worktrees"
6786 );
6787 }
6788
6789 // Now remove a worktree and verify work_dirs shrink.
6790 let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
6791 project.update(&mut cx, |project, cx| {
6792 project.remove_worktree(worktree_b_id, cx);
6793 });
6794 cx.run_until_parked();
6795
6796 let after_remove_b = panel.read_with(&cx, |panel, cx| {
6797 let thread = panel.active_agent_thread(cx).unwrap();
6798 thread.read(cx).work_dirs().cloned().unwrap()
6799 });
6800 assert_eq!(
6801 after_remove_b.ordered_paths().collect::<Vec<_>>(),
6802 vec![&PathBuf::from("/project_a")],
6803 "Thread B work_dirs should revert to only /project_a after removing /project_b"
6804 );
6805
6806 let after_remove_a = panel.read_with(&cx, |panel, cx| {
6807 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6808 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6809 root_thread
6810 .read(cx)
6811 .thread
6812 .read(cx)
6813 .work_dirs()
6814 .cloned()
6815 .unwrap()
6816 });
6817 assert_eq!(
6818 after_remove_a.ordered_paths().collect::<Vec<_>>(),
6819 vec![&PathBuf::from("/project_a")],
6820 "Thread A work_dirs should revert to only /project_a after removing /project_b"
6821 );
6822 }
6823
6824 #[gpui::test]
6825 async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
6826 init_test(cx);
6827 cx.update(|cx| {
6828 agent::ThreadStore::init_global(cx);
6829 language_model::LanguageModelRegistry::test(cx);
6830 // Use an isolated DB so parallel tests can't overwrite our global key.
6831 cx.set_global(db::AppDatabase::test_new());
6832 });
6833
6834 let custom_agent = Agent::Custom {
6835 id: "my-preferred-agent".into(),
6836 };
6837
6838 // Write a known agent to the global KVP to simulate a user who has
6839 // previously used this agent in another workspace.
6840 let kvp = cx.update(|cx| KeyValueStore::global(cx));
6841 write_global_last_used_agent(kvp, custom_agent.clone()).await;
6842
6843 let fs = FakeFs::new(cx.executor());
6844 let project = Project::test(fs.clone(), [], cx).await;
6845
6846 let multi_workspace =
6847 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6848
6849 let workspace = multi_workspace
6850 .read_with(cx, |multi_workspace, _cx| {
6851 multi_workspace.workspace().clone()
6852 })
6853 .unwrap();
6854
6855 workspace.update(cx, |workspace, _cx| {
6856 workspace.set_random_database_id();
6857 });
6858
6859 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6860
6861 // Load the panel via `load()`, which reads the global fallback
6862 // asynchronously when no per-workspace state exists.
6863 let async_cx = cx.update(|window, cx| window.to_async(cx));
6864 let panel = AgentPanel::load(workspace.downgrade(), async_cx)
6865 .await
6866 .expect("panel load should succeed");
6867 cx.run_until_parked();
6868
6869 panel.read_with(cx, |panel, _cx| {
6870 assert_eq!(
6871 panel.selected_agent, custom_agent,
6872 "new workspace should inherit the global last-used agent"
6873 );
6874 });
6875 }
6876
6877 #[gpui::test]
6878 async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
6879 init_test(cx);
6880 cx.update(|cx| {
6881 agent::ThreadStore::init_global(cx);
6882 language_model::LanguageModelRegistry::test(cx);
6883 });
6884
6885 let fs = FakeFs::new(cx.executor());
6886 let project_a = Project::test(fs.clone(), [], cx).await;
6887 let project_b = Project::test(fs, [], cx).await;
6888
6889 let multi_workspace =
6890 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6891
6892 let workspace_a = multi_workspace
6893 .read_with(cx, |multi_workspace, _cx| {
6894 multi_workspace.workspace().clone()
6895 })
6896 .unwrap();
6897
6898 let workspace_b = multi_workspace
6899 .update(cx, |multi_workspace, window, cx| {
6900 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
6901 })
6902 .unwrap();
6903
6904 workspace_a.update(cx, |workspace, _cx| {
6905 workspace.set_random_database_id();
6906 });
6907 workspace_b.update(cx, |workspace, _cx| {
6908 workspace.set_random_database_id();
6909 });
6910
6911 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6912
6913 let agent_a = Agent::Custom {
6914 id: "agent-alpha".into(),
6915 };
6916 let agent_b = Agent::Custom {
6917 id: "agent-beta".into(),
6918 };
6919
6920 // Set up workspace A with agent_a
6921 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
6922 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6923 });
6924 panel_a.update(cx, |panel, _cx| {
6925 panel.selected_agent = agent_a.clone();
6926 });
6927
6928 // Set up workspace B with agent_b
6929 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
6930 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6931 });
6932 panel_b.update(cx, |panel, _cx| {
6933 panel.selected_agent = agent_b.clone();
6934 });
6935
6936 // Serialize both panels
6937 panel_a.update(cx, |panel, cx| panel.serialize(cx));
6938 panel_b.update(cx, |panel, cx| panel.serialize(cx));
6939 cx.run_until_parked();
6940
6941 // Load fresh panels from serialized state and verify independence
6942 let async_cx = cx.update(|window, cx| window.to_async(cx));
6943 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
6944 .await
6945 .expect("panel A load should succeed");
6946 cx.run_until_parked();
6947
6948 let async_cx = cx.update(|window, cx| window.to_async(cx));
6949 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
6950 .await
6951 .expect("panel B load should succeed");
6952 cx.run_until_parked();
6953
6954 loaded_a.read_with(cx, |panel, _cx| {
6955 assert_eq!(
6956 panel.selected_agent, agent_a,
6957 "workspace A should restore agent-alpha, not agent-beta"
6958 );
6959 });
6960
6961 loaded_b.read_with(cx, |panel, _cx| {
6962 assert_eq!(
6963 panel.selected_agent, agent_b,
6964 "workspace B should restore agent-beta, not agent-alpha"
6965 );
6966 });
6967 }
6968
6969 #[gpui::test]
6970 async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
6971 init_test(cx);
6972 cx.update(|cx| {
6973 agent::ThreadStore::init_global(cx);
6974 language_model::LanguageModelRegistry::test(cx);
6975 });
6976
6977 let fs = FakeFs::new(cx.executor());
6978 let project = Project::test(fs.clone(), [], cx).await;
6979
6980 let multi_workspace =
6981 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6982
6983 let workspace = multi_workspace
6984 .read_with(cx, |multi_workspace, _cx| {
6985 multi_workspace.workspace().clone()
6986 })
6987 .unwrap();
6988
6989 workspace.update(cx, |workspace, _cx| {
6990 workspace.set_random_database_id();
6991 });
6992
6993 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6994
6995 let custom_agent = Agent::Custom {
6996 id: "my-custom-agent".into(),
6997 };
6998
6999 let panel = workspace.update_in(cx, |workspace, window, cx| {
7000 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7001 workspace.add_panel(panel.clone(), window, cx);
7002 panel
7003 });
7004
7005 // Set selected_agent to a custom agent
7006 panel.update(cx, |panel, _cx| {
7007 panel.selected_agent = custom_agent.clone();
7008 });
7009
7010 // Call new_thread, which internally calls external_thread(None, ...)
7011 // This resolves the agent from self.selected_agent
7012 panel.update_in(cx, |panel, window, cx| {
7013 panel.new_thread(&NewThread, window, cx);
7014 });
7015
7016 panel.read_with(cx, |panel, _cx| {
7017 assert_eq!(
7018 panel.selected_agent, custom_agent,
7019 "selected_agent should remain the custom agent after new_thread"
7020 );
7021 assert!(
7022 panel.active_conversation_view().is_some(),
7023 "a thread should have been created"
7024 );
7025 });
7026 }
7027
7028 #[gpui::test]
7029 async fn test_rollback_all_succeed_returns_ok(cx: &mut TestAppContext) {
7030 init_test(cx);
7031 let fs = FakeFs::new(cx.executor());
7032 cx.update(|cx| {
7033 cx.update_flags(true, vec!["agent-v2".to_string()]);
7034 agent::ThreadStore::init_global(cx);
7035 language_model::LanguageModelRegistry::test(cx);
7036 <dyn fs::Fs>::set_global(fs.clone(), cx);
7037 });
7038
7039 fs.insert_tree(
7040 "/project",
7041 json!({
7042 ".git": {},
7043 "src": { "main.rs": "fn main() {}" }
7044 }),
7045 )
7046 .await;
7047
7048 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7049 cx.executor().run_until_parked();
7050
7051 let repository = project.read_with(cx, |project, cx| {
7052 project.repositories(cx).values().next().unwrap().clone()
7053 });
7054
7055 let multi_workspace =
7056 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7057
7058 let path_a = PathBuf::from("/worktrees/branch/project_a");
7059 let path_b = PathBuf::from("/worktrees/branch/project_b");
7060
7061 let (sender_a, receiver_a) = futures::channel::oneshot::channel::<Result<()>>();
7062 let (sender_b, receiver_b) = futures::channel::oneshot::channel::<Result<()>>();
7063 sender_a.send(Ok(())).unwrap();
7064 sender_b.send(Ok(())).unwrap();
7065
7066 let creation_infos = vec![
7067 (repository.clone(), path_a.clone(), receiver_a),
7068 (repository.clone(), path_b.clone(), receiver_b),
7069 ];
7070
7071 let fs_clone = fs.clone();
7072 let result = multi_workspace
7073 .update(cx, |_, window, cx| {
7074 window.spawn(cx, async move |cx| {
7075 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7076 })
7077 })
7078 .unwrap()
7079 .await;
7080
7081 let paths = result.expect("all succeed should return Ok");
7082 assert_eq!(paths, vec![path_a, path_b]);
7083 }
7084
7085 #[gpui::test]
7086 async fn test_rollback_on_failure_attempts_all_worktrees(cx: &mut TestAppContext) {
7087 init_test(cx);
7088 let fs = FakeFs::new(cx.executor());
7089 cx.update(|cx| {
7090 cx.update_flags(true, vec!["agent-v2".to_string()]);
7091 agent::ThreadStore::init_global(cx);
7092 language_model::LanguageModelRegistry::test(cx);
7093 <dyn fs::Fs>::set_global(fs.clone(), cx);
7094 });
7095
7096 fs.insert_tree(
7097 "/project",
7098 json!({
7099 ".git": {},
7100 "src": { "main.rs": "fn main() {}" }
7101 }),
7102 )
7103 .await;
7104
7105 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7106 cx.executor().run_until_parked();
7107
7108 let repository = project.read_with(cx, |project, cx| {
7109 project.repositories(cx).values().next().unwrap().clone()
7110 });
7111
7112 // Actually create a worktree so it exists in FakeFs for rollback to find.
7113 let success_path = PathBuf::from("/worktrees/branch/project");
7114 cx.update(|cx| {
7115 repository.update(cx, |repo, _| {
7116 repo.create_worktree(
7117 git::repository::CreateWorktreeTarget::NewBranch {
7118 branch_name: "branch".to_string(),
7119 base_sha: None,
7120 },
7121 success_path.clone(),
7122 )
7123 })
7124 })
7125 .await
7126 .unwrap()
7127 .unwrap();
7128 cx.executor().run_until_parked();
7129
7130 // Verify the worktree directory exists before rollback.
7131 assert!(
7132 fs.is_dir(&success_path).await,
7133 "worktree directory should exist before rollback"
7134 );
7135
7136 let multi_workspace =
7137 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7138
7139 // Build creation_infos: one success, one failure.
7140 let failed_path = PathBuf::from("/worktrees/branch/failed_project");
7141
7142 let (sender_ok, receiver_ok) = futures::channel::oneshot::channel::<Result<()>>();
7143 let (sender_err, receiver_err) = futures::channel::oneshot::channel::<Result<()>>();
7144 sender_ok.send(Ok(())).unwrap();
7145 sender_err
7146 .send(Err(anyhow!("branch already exists")))
7147 .unwrap();
7148
7149 let creation_infos = vec![
7150 (repository.clone(), success_path.clone(), receiver_ok),
7151 (repository.clone(), failed_path.clone(), receiver_err),
7152 ];
7153
7154 let fs_clone = fs.clone();
7155 let result = multi_workspace
7156 .update(cx, |_, window, cx| {
7157 window.spawn(cx, async move |cx| {
7158 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7159 })
7160 })
7161 .unwrap()
7162 .await;
7163
7164 assert!(
7165 result.is_err(),
7166 "should return error when any creation fails"
7167 );
7168 let err_msg = result.unwrap_err().to_string();
7169 assert!(
7170 err_msg.contains("branch already exists"),
7171 "error should mention the original failure: {err_msg}"
7172 );
7173
7174 // The successful worktree should have been rolled back by git.
7175 cx.executor().run_until_parked();
7176 assert!(
7177 !fs.is_dir(&success_path).await,
7178 "successful worktree directory should be removed by rollback"
7179 );
7180 }
7181
7182 #[gpui::test]
7183 async fn test_rollback_on_canceled_receiver(cx: &mut TestAppContext) {
7184 init_test(cx);
7185 let fs = FakeFs::new(cx.executor());
7186 cx.update(|cx| {
7187 cx.update_flags(true, vec!["agent-v2".to_string()]);
7188 agent::ThreadStore::init_global(cx);
7189 language_model::LanguageModelRegistry::test(cx);
7190 <dyn fs::Fs>::set_global(fs.clone(), cx);
7191 });
7192
7193 fs.insert_tree(
7194 "/project",
7195 json!({
7196 ".git": {},
7197 "src": { "main.rs": "fn main() {}" }
7198 }),
7199 )
7200 .await;
7201
7202 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7203 cx.executor().run_until_parked();
7204
7205 let repository = project.read_with(cx, |project, cx| {
7206 project.repositories(cx).values().next().unwrap().clone()
7207 });
7208
7209 let multi_workspace =
7210 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7211
7212 let path = PathBuf::from("/worktrees/branch/project");
7213
7214 // Drop the sender to simulate a canceled receiver.
7215 let (_sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
7216 drop(_sender);
7217
7218 let creation_infos = vec![(repository.clone(), path.clone(), receiver)];
7219
7220 let fs_clone = fs.clone();
7221 let result = multi_workspace
7222 .update(cx, |_, window, cx| {
7223 window.spawn(cx, async move |cx| {
7224 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7225 })
7226 })
7227 .unwrap()
7228 .await;
7229
7230 assert!(
7231 result.is_err(),
7232 "should return error when receiver is canceled"
7233 );
7234 let err_msg = result.unwrap_err().to_string();
7235 assert!(
7236 err_msg.contains("canceled"),
7237 "error should mention cancellation: {err_msg}"
7238 );
7239 }
7240
7241 #[gpui::test]
7242 async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) {
7243 init_test(cx);
7244 let fs = FakeFs::new(cx.executor());
7245 cx.update(|cx| {
7246 cx.update_flags(true, vec!["agent-v2".to_string()]);
7247 agent::ThreadStore::init_global(cx);
7248 language_model::LanguageModelRegistry::test(cx);
7249 <dyn fs::Fs>::set_global(fs.clone(), cx);
7250 });
7251
7252 fs.insert_tree(
7253 "/project",
7254 json!({
7255 ".git": {},
7256 "src": { "main.rs": "fn main() {}" }
7257 }),
7258 )
7259 .await;
7260
7261 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7262 cx.executor().run_until_parked();
7263
7264 let repository = project.read_with(cx, |project, cx| {
7265 project.repositories(cx).values().next().unwrap().clone()
7266 });
7267
7268 let multi_workspace =
7269 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7270
7271 // Simulate the orphan state: create_dir_all was called but git
7272 // worktree add failed, leaving a directory with leftover files.
7273 let orphan_path = PathBuf::from("/worktrees/branch/orphan_project");
7274 fs.insert_tree(
7275 "/worktrees/branch/orphan_project",
7276 json!({ "leftover.txt": "junk" }),
7277 )
7278 .await;
7279
7280 assert!(
7281 fs.is_dir(&orphan_path).await,
7282 "orphan dir should exist before rollback"
7283 );
7284
7285 let (sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
7286 sender.send(Err(anyhow!("hook failed"))).unwrap();
7287
7288 let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)];
7289
7290 let fs_clone = fs.clone();
7291 let result = multi_workspace
7292 .update(cx, |_, window, cx| {
7293 window.spawn(cx, async move |cx| {
7294 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7295 })
7296 })
7297 .unwrap()
7298 .await;
7299
7300 cx.executor().run_until_parked();
7301
7302 assert!(result.is_err());
7303 assert!(
7304 !fs.is_dir(&orphan_path).await,
7305 "orphan worktree directory should be removed by filesystem cleanup"
7306 );
7307 }
7308
7309 #[gpui::test]
7310 async fn test_worktree_creation_for_remote_project(
7311 cx: &mut TestAppContext,
7312 server_cx: &mut TestAppContext,
7313 ) {
7314 init_test(cx);
7315
7316 let app_state = cx.update(|cx| {
7317 agent::ThreadStore::init_global(cx);
7318 language_model::LanguageModelRegistry::test(cx);
7319
7320 let app_state = workspace::AppState::test(cx);
7321 workspace::init(app_state.clone(), cx);
7322 app_state
7323 });
7324
7325 server_cx.update(|cx| {
7326 release_channel::init(semver::Version::new(0, 0, 0), cx);
7327 });
7328
7329 // Set up the remote server side with a git repo.
7330 let server_fs = FakeFs::new(server_cx.executor());
7331 server_fs
7332 .insert_tree(
7333 "/project",
7334 json!({
7335 ".git": {},
7336 "src": {
7337 "main.rs": "fn main() {}"
7338 }
7339 }),
7340 )
7341 .await;
7342 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
7343
7344 // Create a mock remote connection.
7345 let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
7346
7347 server_cx.update(remote_server::HeadlessProject::init);
7348 let server_executor = server_cx.executor();
7349 let _headless = server_cx.new(|cx| {
7350 remote_server::HeadlessProject::new(
7351 remote_server::HeadlessAppState {
7352 session: server_session,
7353 fs: server_fs.clone(),
7354 http_client: Arc::new(http_client::BlockedHttpClient),
7355 node_runtime: node_runtime::NodeRuntime::unavailable(),
7356 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
7357 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
7358 startup_time: Instant::now(),
7359 },
7360 false,
7361 cx,
7362 )
7363 });
7364
7365 // Connect the client side and build a remote project.
7366 // Use a separate Client to avoid double-registering proto handlers
7367 // (Workspace::test_new creates its own WorkspaceStore from the
7368 // project's client).
7369 let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
7370 let project = cx.update(|cx| {
7371 let project_client = client::Client::new(
7372 Arc::new(clock::FakeSystemClock::new()),
7373 http_client::FakeHttpClient::with_404_response(),
7374 cx,
7375 );
7376 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
7377 project::Project::remote(
7378 remote_client,
7379 project_client,
7380 node_runtime::NodeRuntime::unavailable(),
7381 user_store,
7382 app_state.languages.clone(),
7383 app_state.fs.clone(),
7384 false,
7385 cx,
7386 )
7387 });
7388
7389 // Open the remote path as a worktree in the project.
7390 let worktree_path = Path::new("/project");
7391 project
7392 .update(cx, |project, cx| {
7393 project.find_or_create_worktree(worktree_path, true, cx)
7394 })
7395 .await
7396 .expect("should be able to open remote worktree");
7397 cx.run_until_parked();
7398
7399 // Verify the project is indeed remote.
7400 project.read_with(cx, |project, cx| {
7401 assert!(!project.is_local(), "project should be remote, not local");
7402 assert!(
7403 project.remote_connection_options(cx).is_some(),
7404 "project should have remote connection options"
7405 );
7406 });
7407
7408 // Create the workspace and agent panel.
7409 let multi_workspace =
7410 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7411 multi_workspace
7412 .update(cx, |multi_workspace, _, cx| {
7413 multi_workspace.open_sidebar(cx);
7414 })
7415 .unwrap();
7416
7417 let workspace = multi_workspace
7418 .read_with(cx, |mw, _cx| mw.workspace().clone())
7419 .unwrap();
7420
7421 workspace.update(cx, |workspace, _cx| {
7422 workspace.set_random_database_id();
7423 });
7424
7425 // Register a callback so new workspaces also get an AgentPanel.
7426 cx.update(|cx| {
7427 cx.observe_new(
7428 |workspace: &mut Workspace,
7429 window: Option<&mut Window>,
7430 cx: &mut Context<Workspace>| {
7431 if let Some(window) = window {
7432 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7433 workspace.add_panel(panel, window, cx);
7434 }
7435 },
7436 )
7437 .detach();
7438 });
7439
7440 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7441 cx.run_until_parked();
7442
7443 let panel = workspace.update_in(cx, |workspace, window, cx| {
7444 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7445 workspace.add_panel(panel.clone(), window, cx);
7446 panel
7447 });
7448
7449 cx.run_until_parked();
7450
7451 // Open a thread.
7452 panel.update_in(cx, |panel, window, cx| {
7453 panel.open_external_thread_with_server(
7454 Rc::new(StubAgentServer::default_response()),
7455 window,
7456 cx,
7457 );
7458 });
7459 cx.run_until_parked();
7460
7461 // Set start_thread_in to LinkedWorktree to bypass git worktree
7462 // creation and directly test workspace opening for a known path.
7463 let linked_path = PathBuf::from("/project");
7464 panel.update_in(cx, |panel, window, cx| {
7465 panel.set_start_thread_in(
7466 &StartThreadIn::LinkedWorktree {
7467 path: linked_path.clone(),
7468 display_name: "project".to_string(),
7469 },
7470 window,
7471 cx,
7472 );
7473 });
7474
7475 // Trigger worktree creation.
7476 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
7477 "Hello from remote test",
7478 ))];
7479 panel.update_in(cx, |panel, window, cx| {
7480 panel.handle_worktree_requested(
7481 content,
7482 WorktreeCreationArgs::Linked {
7483 worktree_path: linked_path,
7484 },
7485 window,
7486 cx,
7487 );
7488 });
7489
7490 // The refactored code uses `find_or_create_workspace`, which
7491 // finds the existing remote workspace (matching paths + host)
7492 // and reuses it instead of creating a new connection.
7493 cx.run_until_parked();
7494
7495 // The task should have completed: the existing workspace was
7496 // found and reused.
7497 panel.read_with(cx, |panel, _cx| {
7498 assert!(
7499 panel.worktree_creation_status.is_none(),
7500 "worktree creation should have completed, but status is: {:?}",
7501 panel.worktree_creation_status
7502 );
7503 });
7504
7505 // The existing remote workspace was reused — no new workspace
7506 // should have been created.
7507 multi_workspace
7508 .read_with(cx, |multi_workspace, cx| {
7509 let project = workspace.read(cx).project().clone();
7510 assert!(
7511 !project.read(cx).is_local(),
7512 "workspace project should still be remote, not local"
7513 );
7514 assert_eq!(
7515 multi_workspace.workspaces().count(),
7516 1,
7517 "existing remote workspace should be reused, not a new one created"
7518 );
7519 })
7520 .unwrap();
7521 }
7522}