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