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