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