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.background_spawn(async move {
2592 task.await.log_err();
2593 }));
2594 }
2595
2596 async fn setup_new_workspace(
2597 this: WeakEntity<Self>,
2598 all_paths: Vec<PathBuf>,
2599 app_state: Arc<workspace::AppState>,
2600 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
2601 active_file_path: Option<PathBuf>,
2602 path_remapping: Vec<(PathBuf, PathBuf)>,
2603 non_git_paths: Vec<PathBuf>,
2604 has_non_git: bool,
2605 content: Vec<acp::ContentBlock>,
2606 selected_agent: Option<Agent>,
2607 cx: &mut AsyncWindowContext,
2608 ) -> Result<()> {
2609 let OpenResult {
2610 window: new_window_handle,
2611 workspace: new_workspace,
2612 ..
2613 } = cx
2614 .update(|_window, cx| {
2615 Workspace::new_local(
2616 all_paths,
2617 app_state,
2618 window_handle,
2619 None,
2620 None,
2621 OpenMode::Add,
2622 cx,
2623 )
2624 })?
2625 .await?;
2626
2627 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
2628
2629 if let Some(task) = panels_task {
2630 task.await.log_err();
2631 }
2632
2633 new_workspace
2634 .update(cx, |workspace, cx| {
2635 workspace.project().read(cx).wait_for_initial_scan(cx)
2636 })
2637 .await;
2638
2639 new_workspace
2640 .update(cx, |workspace, cx| {
2641 let repos = workspace
2642 .project()
2643 .read(cx)
2644 .repositories(cx)
2645 .values()
2646 .cloned()
2647 .collect::<Vec<_>>();
2648
2649 let tasks = repos
2650 .into_iter()
2651 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
2652 futures::future::join_all(tasks)
2653 })
2654 .await;
2655
2656 let initial_content = AgentInitialContent::ContentBlock {
2657 blocks: content,
2658 auto_submit: true,
2659 };
2660
2661 new_window_handle.update(cx, |_multi_workspace, window, cx| {
2662 new_workspace.update(cx, |workspace, cx| {
2663 if has_non_git {
2664 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
2665 workspace.show_toast(
2666 workspace::Toast::new(
2667 toast_id,
2668 "Some project folders are not git repositories. \
2669 They were included as-is without creating a worktree.",
2670 ),
2671 cx,
2672 );
2673 }
2674
2675 // If we had an active buffer, remap its path and reopen it.
2676 let had_active_file = active_file_path.is_some();
2677 let remapped_active_path = active_file_path.and_then(|original_path| {
2678 let best_match = path_remapping
2679 .iter()
2680 .filter_map(|(old_root, new_root)| {
2681 original_path.strip_prefix(old_root).ok().map(|relative| {
2682 (old_root.components().count(), new_root.join(relative))
2683 })
2684 })
2685 .max_by_key(|(depth, _)| *depth);
2686
2687 if let Some((_, remapped_path)) = best_match {
2688 return Some(remapped_path);
2689 }
2690
2691 for non_git in &non_git_paths {
2692 if original_path.starts_with(non_git) {
2693 return Some(original_path);
2694 }
2695 }
2696 None
2697 });
2698
2699 if had_active_file && remapped_active_path.is_none() {
2700 log::warn!(
2701 "Active file could not be remapped to the new worktree; it will not be reopened"
2702 );
2703 }
2704
2705 if let Some(path) = remapped_active_path {
2706 let open_task = workspace.open_paths(
2707 vec![path],
2708 workspace::OpenOptions::default(),
2709 None,
2710 window,
2711 cx,
2712 );
2713 cx.spawn(async move |_, _| -> anyhow::Result<()> {
2714 for item in open_task.await.into_iter().flatten() {
2715 item?;
2716 }
2717 Ok(())
2718 })
2719 .detach_and_log_err(cx);
2720 }
2721
2722 workspace.focus_panel::<AgentPanel>(window, cx);
2723
2724 // If no active buffer was open, zoom the agent panel
2725 // (equivalent to cmd-esc fullscreen behavior).
2726 // This must happen after focus_panel, which activates
2727 // and opens the panel in the dock.
2728
2729 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2730 panel.update(cx, |panel, cx| {
2731 panel.external_thread(
2732 selected_agent,
2733 None,
2734 None,
2735 None,
2736 Some(initial_content),
2737 true,
2738 window,
2739 cx,
2740 );
2741 });
2742 }
2743 });
2744 })?;
2745
2746 new_window_handle.update(cx, |multi_workspace, window, cx| {
2747 multi_workspace.activate(new_workspace.clone(), window, cx);
2748
2749 new_workspace.update(cx, |workspace, cx| {
2750 workspace.run_create_worktree_tasks(window, cx);
2751 })
2752 })?;
2753
2754 this.update_in(cx, |this, window, cx| {
2755 this.worktree_creation_status = None;
2756
2757 if let Some(thread_view) = this.active_thread_view(cx) {
2758 thread_view.update(cx, |thread_view, cx| {
2759 thread_view
2760 .message_editor
2761 .update(cx, |editor, cx| editor.clear(window, cx));
2762 });
2763 }
2764
2765 cx.notify();
2766 })?;
2767
2768 anyhow::Ok(())
2769 }
2770}
2771
2772impl Focusable for AgentPanel {
2773 fn focus_handle(&self, cx: &App) -> FocusHandle {
2774 match &self.active_view {
2775 ActiveView::Uninitialized => self.focus_handle.clone(),
2776 ActiveView::AgentThread {
2777 conversation_view, ..
2778 } => conversation_view.focus_handle(cx),
2779 ActiveView::History { view } => view.read(cx).focus_handle(cx),
2780 ActiveView::Configuration => {
2781 if let Some(configuration) = self.configuration.as_ref() {
2782 configuration.focus_handle(cx)
2783 } else {
2784 self.focus_handle.clone()
2785 }
2786 }
2787 }
2788 }
2789}
2790
2791fn agent_panel_dock_position(cx: &App) -> DockPosition {
2792 AgentSettings::get_global(cx).dock.into()
2793}
2794
2795pub enum AgentPanelEvent {
2796 ActiveViewChanged,
2797 ThreadFocused,
2798 BackgroundThreadChanged,
2799 MessageSentOrQueued { session_id: acp::SessionId },
2800}
2801
2802impl EventEmitter<PanelEvent> for AgentPanel {}
2803impl EventEmitter<AgentPanelEvent> for AgentPanel {}
2804
2805impl Panel for AgentPanel {
2806 fn persistent_name() -> &'static str {
2807 "AgentPanel"
2808 }
2809
2810 fn panel_key() -> &'static str {
2811 AGENT_PANEL_KEY
2812 }
2813
2814 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
2815 agent_panel_dock_position(cx)
2816 }
2817
2818 fn position_is_valid(&self, position: DockPosition) -> bool {
2819 position != DockPosition::Bottom
2820 }
2821
2822 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2823 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
2824 settings
2825 .agent
2826 .get_or_insert_default()
2827 .set_dock(position.into());
2828 });
2829 }
2830
2831 fn default_size(&self, window: &Window, cx: &App) -> Pixels {
2832 let settings = AgentSettings::get_global(cx);
2833 match self.position(window, cx) {
2834 DockPosition::Left | DockPosition::Right => settings.default_width,
2835 DockPosition::Bottom => settings.default_height,
2836 }
2837 }
2838
2839 fn supports_flexible_size(&self) -> bool {
2840 true
2841 }
2842
2843 fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
2844 AgentSettings::get_global(cx).flexible
2845 }
2846
2847 fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
2848 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
2849 settings
2850 .agent
2851 .get_or_insert_default()
2852 .set_flexible_size(flexible);
2853 });
2854 }
2855
2856 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
2857 if active
2858 && matches!(self.active_view, ActiveView::Uninitialized)
2859 && !matches!(
2860 self.worktree_creation_status,
2861 Some(WorktreeCreationStatus::Creating)
2862 )
2863 {
2864 let selected_agent = self.selected_agent.clone();
2865 self.new_agent_thread_inner(selected_agent, false, window, cx);
2866 }
2867 }
2868
2869 fn remote_id() -> Option<proto::PanelId> {
2870 Some(proto::PanelId::AssistantPanel)
2871 }
2872
2873 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
2874 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
2875 }
2876
2877 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2878 Some("Agent Panel")
2879 }
2880
2881 fn toggle_action(&self) -> Box<dyn Action> {
2882 Box::new(ToggleFocus)
2883 }
2884
2885 fn activation_priority(&self) -> u32 {
2886 0
2887 }
2888
2889 fn enabled(&self, cx: &App) -> bool {
2890 AgentSettings::get_global(cx).enabled(cx)
2891 }
2892
2893 fn is_agent_panel(&self) -> bool {
2894 true
2895 }
2896
2897 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
2898 self.zoomed
2899 }
2900
2901 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
2902 self.zoomed = zoomed;
2903 cx.notify();
2904 }
2905}
2906
2907impl AgentPanel {
2908 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
2909 let content = match &self.active_view {
2910 ActiveView::AgentThread { conversation_view } => {
2911 let server_view_ref = conversation_view.read(cx);
2912 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
2913 && server_view_ref.root_thread(cx).map_or(false, |tv| {
2914 tv.read(cx).thread.read(cx).has_provisional_title()
2915 });
2916
2917 if let Some(title_editor) = server_view_ref
2918 .root_thread(cx)
2919 .map(|r| r.read(cx).title_editor.clone())
2920 {
2921 if is_generating_title {
2922 Label::new(DEFAULT_THREAD_TITLE)
2923 .color(Color::Muted)
2924 .truncate()
2925 .with_animation(
2926 "generating_title",
2927 Animation::new(Duration::from_secs(2))
2928 .repeat()
2929 .with_easing(pulsating_between(0.4, 0.8)),
2930 |label, delta| label.alpha(delta),
2931 )
2932 .into_any_element()
2933 } else {
2934 div()
2935 .w_full()
2936 .on_action({
2937 let conversation_view = conversation_view.downgrade();
2938 move |_: &menu::Confirm, window, cx| {
2939 if let Some(conversation_view) = conversation_view.upgrade() {
2940 conversation_view.focus_handle(cx).focus(window, cx);
2941 }
2942 }
2943 })
2944 .on_action({
2945 let conversation_view = conversation_view.downgrade();
2946 move |_: &editor::actions::Cancel, window, cx| {
2947 if let Some(conversation_view) = conversation_view.upgrade() {
2948 conversation_view.focus_handle(cx).focus(window, cx);
2949 }
2950 }
2951 })
2952 .child(title_editor)
2953 .into_any_element()
2954 }
2955 } else {
2956 Label::new(conversation_view.read(cx).title(cx))
2957 .color(Color::Muted)
2958 .truncate()
2959 .into_any_element()
2960 }
2961 }
2962 ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
2963 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
2964 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
2965 };
2966
2967 h_flex()
2968 .key_context("TitleEditor")
2969 .id("TitleEditor")
2970 .flex_grow()
2971 .w_full()
2972 .max_w_full()
2973 .overflow_x_scroll()
2974 .child(content)
2975 .into_any()
2976 }
2977
2978 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
2979 conversation_view.update(cx, |conversation_view, cx| {
2980 if let Some(thread) = conversation_view.as_native_thread(cx) {
2981 thread.update(cx, |thread, cx| {
2982 thread.generate_title(cx);
2983 });
2984 }
2985 });
2986 }
2987
2988 fn render_panel_options_menu(
2989 &self,
2990 window: &mut Window,
2991 cx: &mut Context<Self>,
2992 ) -> impl IntoElement {
2993 let focus_handle = self.focus_handle(cx);
2994
2995 let full_screen_label = if self.is_zoomed(window, cx) {
2996 "Disable Full Screen"
2997 } else {
2998 "Enable Full Screen"
2999 };
3000
3001 let conversation_view = match &self.active_view {
3002 ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3003 _ => None,
3004 };
3005 let thread_with_messages = match &self.active_view {
3006 ActiveView::AgentThread { conversation_view } => {
3007 conversation_view.read(cx).has_user_submitted_prompt(cx)
3008 }
3009 _ => false,
3010 };
3011 let has_auth_methods = match &self.active_view {
3012 ActiveView::AgentThread { conversation_view } => {
3013 conversation_view.read(cx).has_auth_methods()
3014 }
3015 _ => false,
3016 };
3017
3018 PopoverMenu::new("agent-options-menu")
3019 .trigger_with_tooltip(
3020 IconButton::new("agent-options-menu", IconName::Ellipsis)
3021 .icon_size(IconSize::Small),
3022 {
3023 let focus_handle = focus_handle.clone();
3024 move |_window, cx| {
3025 Tooltip::for_action_in(
3026 "Toggle Agent Menu",
3027 &ToggleOptionsMenu,
3028 &focus_handle,
3029 cx,
3030 )
3031 }
3032 },
3033 )
3034 .anchor(Corner::TopRight)
3035 .with_handle(self.agent_panel_menu_handle.clone())
3036 .menu({
3037 move |window, cx| {
3038 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3039 menu = menu.context(focus_handle.clone());
3040
3041 if thread_with_messages {
3042 menu = menu.header("Current Thread");
3043
3044 if let Some(conversation_view) = conversation_view.as_ref() {
3045 menu = menu
3046 .entry("Regenerate Thread Title", None, {
3047 let conversation_view = conversation_view.clone();
3048 move |_, cx| {
3049 Self::handle_regenerate_thread_title(
3050 conversation_view.clone(),
3051 cx,
3052 );
3053 }
3054 })
3055 .separator();
3056 }
3057 }
3058
3059 menu = menu
3060 .header("MCP Servers")
3061 .action(
3062 "View Server Extensions",
3063 Box::new(zed_actions::Extensions {
3064 category_filter: Some(
3065 zed_actions::ExtensionCategoryFilter::ContextServers,
3066 ),
3067 id: None,
3068 }),
3069 )
3070 .action("Add Custom Server…", Box::new(AddContextServer))
3071 .separator()
3072 .action("Rules", Box::new(OpenRulesLibrary::default()))
3073 .action("Profiles", Box::new(ManageProfiles::default()))
3074 .action("Settings", Box::new(OpenSettings))
3075 .separator()
3076 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar))
3077 .action(full_screen_label, Box::new(ToggleZoom));
3078
3079 if has_auth_methods {
3080 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3081 }
3082
3083 menu
3084 }))
3085 }
3086 })
3087 }
3088
3089 fn render_recent_entries_menu(
3090 &self,
3091 icon: IconName,
3092 corner: Corner,
3093 cx: &mut Context<Self>,
3094 ) -> impl IntoElement {
3095 let focus_handle = self.focus_handle(cx);
3096
3097 PopoverMenu::new("agent-nav-menu")
3098 .trigger_with_tooltip(
3099 IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
3100 {
3101 move |_window, cx| {
3102 Tooltip::for_action_in(
3103 "Toggle Recently Updated Threads",
3104 &ToggleNavigationMenu,
3105 &focus_handle,
3106 cx,
3107 )
3108 }
3109 },
3110 )
3111 .anchor(corner)
3112 .with_handle(self.agent_navigation_menu_handle.clone())
3113 .menu({
3114 let menu = self.agent_navigation_menu.clone();
3115 move |window, cx| {
3116 telemetry::event!("View Thread History Clicked");
3117
3118 if let Some(menu) = menu.as_ref() {
3119 menu.update(cx, |_, cx| {
3120 cx.defer_in(window, |menu, window, cx| {
3121 menu.rebuild(window, cx);
3122 });
3123 })
3124 }
3125 menu.clone()
3126 }
3127 })
3128 }
3129
3130 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3131 let focus_handle = self.focus_handle(cx);
3132
3133 IconButton::new("go-back", IconName::ArrowLeft)
3134 .icon_size(IconSize::Small)
3135 .on_click(cx.listener(|this, _, window, cx| {
3136 this.go_back(&workspace::GoBack, window, cx);
3137 }))
3138 .tooltip({
3139 move |_window, cx| {
3140 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3141 }
3142 })
3143 }
3144
3145 fn project_has_git_repository(&self, cx: &App) -> bool {
3146 !self.project.read(cx).repositories(cx).is_empty()
3147 }
3148
3149 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3150 use settings::{NewThreadLocation, Settings};
3151
3152 let focus_handle = self.focus_handle(cx);
3153 let has_git_repo = self.project_has_git_repository(cx);
3154 let is_via_collab = self.project.read(cx).is_via_collab();
3155 let fs = self.fs.clone();
3156
3157 let is_creating = matches!(
3158 self.worktree_creation_status,
3159 Some(WorktreeCreationStatus::Creating)
3160 );
3161
3162 let current_target = self.start_thread_in;
3163 let trigger_label = self.start_thread_in.label();
3164
3165 let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
3166 let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
3167 let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
3168
3169 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3170 IconName::ChevronUp
3171 } else {
3172 IconName::ChevronDown
3173 };
3174
3175 let trigger_button = Button::new("thread-target-trigger", trigger_label)
3176 .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
3177 .disabled(is_creating);
3178
3179 let dock_position = AgentSettings::get_global(cx).dock;
3180 let documentation_side = match dock_position {
3181 settings::DockPosition::Left => DocumentationSide::Right,
3182 settings::DockPosition::Bottom | settings::DockPosition::Right => {
3183 DocumentationSide::Left
3184 }
3185 };
3186
3187 PopoverMenu::new("thread-target-selector")
3188 .trigger_with_tooltip(trigger_button, {
3189 move |_window, cx| {
3190 Tooltip::for_action_in(
3191 "Start Thread In…",
3192 &CycleStartThreadIn,
3193 &focus_handle,
3194 cx,
3195 )
3196 }
3197 })
3198 .menu(move |window, cx| {
3199 let is_local_selected = current_target == StartThreadIn::LocalProject;
3200 let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
3201 let fs = fs.clone();
3202
3203 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
3204 let new_worktree_disabled = !has_git_repo || is_via_collab;
3205
3206 menu.header("Start Thread In…")
3207 .item(
3208 ContextMenuEntry::new("Current Worktree")
3209 .toggleable(IconPosition::End, is_local_selected)
3210 .documentation_aside(documentation_side, move |_| {
3211 HoldForDefault::new(is_local_default)
3212 .more_content(false)
3213 .into_any_element()
3214 })
3215 .handler({
3216 let fs = fs.clone();
3217 move |window, cx| {
3218 if window.modifiers().secondary() {
3219 update_settings_file(fs.clone(), cx, |settings, _| {
3220 settings
3221 .agent
3222 .get_or_insert_default()
3223 .set_new_thread_location(
3224 NewThreadLocation::LocalProject,
3225 );
3226 });
3227 }
3228 window.dispatch_action(
3229 Box::new(StartThreadIn::LocalProject),
3230 cx,
3231 );
3232 }
3233 }),
3234 )
3235 .item({
3236 let entry = ContextMenuEntry::new("New Git Worktree")
3237 .toggleable(IconPosition::End, is_new_worktree_selected)
3238 .disabled(new_worktree_disabled)
3239 .handler({
3240 let fs = fs.clone();
3241 move |window, cx| {
3242 if window.modifiers().secondary() {
3243 update_settings_file(fs.clone(), cx, |settings, _| {
3244 settings
3245 .agent
3246 .get_or_insert_default()
3247 .set_new_thread_location(
3248 NewThreadLocation::NewWorktree,
3249 );
3250 });
3251 }
3252 window.dispatch_action(
3253 Box::new(StartThreadIn::NewWorktree),
3254 cx,
3255 );
3256 }
3257 });
3258
3259 if new_worktree_disabled {
3260 entry.documentation_aside(documentation_side, move |_| {
3261 let reason = if !has_git_repo {
3262 "No git repository found in this project."
3263 } else {
3264 "Not available for remote/collab projects yet."
3265 };
3266 Label::new(reason)
3267 .color(Color::Muted)
3268 .size(LabelSize::Small)
3269 .into_any_element()
3270 })
3271 } else {
3272 entry.documentation_aside(documentation_side, move |_| {
3273 HoldForDefault::new(is_new_worktree_default)
3274 .more_content(false)
3275 .into_any_element()
3276 })
3277 }
3278 })
3279 }))
3280 })
3281 .with_handle(self.start_thread_in_menu_handle.clone())
3282 .anchor(Corner::TopLeft)
3283 .offset(gpui::Point {
3284 x: px(1.0),
3285 y: px(1.0),
3286 })
3287 }
3288
3289 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3290 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3291 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3292 let focus_handle = self.focus_handle(cx);
3293
3294 let (selected_agent_custom_icon, selected_agent_label) =
3295 if let Agent::Custom { id, .. } = &self.selected_agent {
3296 let store = agent_server_store.read(cx);
3297 let icon = store.agent_icon(&id);
3298
3299 let label = store
3300 .agent_display_name(&id)
3301 .unwrap_or_else(|| self.selected_agent.label());
3302 (icon, label)
3303 } else {
3304 (None, self.selected_agent.label())
3305 };
3306
3307 let active_thread = match &self.active_view {
3308 ActiveView::AgentThread { conversation_view } => {
3309 conversation_view.read(cx).as_native_thread(cx)
3310 }
3311 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3312 None
3313 }
3314 };
3315
3316 let new_thread_menu_builder: Rc<
3317 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3318 > = {
3319 let selected_agent = self.selected_agent.clone();
3320 let is_agent_selected = move |agent: Agent| selected_agent == agent;
3321
3322 let workspace = self.workspace.clone();
3323 let is_via_collab = workspace
3324 .update(cx, |workspace, cx| {
3325 workspace.project().read(cx).is_via_collab()
3326 })
3327 .unwrap_or_default();
3328
3329 let focus_handle = focus_handle.clone();
3330 let agent_server_store = agent_server_store;
3331
3332 Rc::new(move |window, cx| {
3333 telemetry::event!("New Thread Clicked");
3334
3335 let active_thread = active_thread.clone();
3336 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3337 menu.context(focus_handle.clone())
3338 .when_some(active_thread, |this, active_thread| {
3339 let thread = active_thread.read(cx);
3340
3341 if !thread.is_empty() {
3342 let session_id = thread.id().clone();
3343 this.item(
3344 ContextMenuEntry::new("New From Summary")
3345 .icon(IconName::ThreadFromSummary)
3346 .icon_color(Color::Muted)
3347 .handler(move |window, cx| {
3348 window.dispatch_action(
3349 Box::new(NewNativeAgentThreadFromSummary {
3350 from_session_id: session_id.clone(),
3351 }),
3352 cx,
3353 );
3354 }),
3355 )
3356 } else {
3357 this
3358 }
3359 })
3360 .item(
3361 ContextMenuEntry::new("Zed Agent")
3362 .when(is_agent_selected(Agent::NativeAgent), |this| {
3363 this.action(Box::new(NewExternalAgentThread { agent: None }))
3364 })
3365 .icon(IconName::ZedAgent)
3366 .icon_color(Color::Muted)
3367 .handler({
3368 let workspace = workspace.clone();
3369 move |window, cx| {
3370 if let Some(workspace) = workspace.upgrade() {
3371 workspace.update(cx, |workspace, cx| {
3372 if let Some(panel) =
3373 workspace.panel::<AgentPanel>(cx)
3374 {
3375 panel.update(cx, |panel, cx| {
3376 panel.new_agent_thread(
3377 Agent::NativeAgent,
3378 window,
3379 cx,
3380 );
3381 });
3382 }
3383 });
3384 }
3385 }
3386 }),
3387 )
3388 .map(|mut menu| {
3389 let agent_server_store = agent_server_store.read(cx);
3390 let registry_store = project::AgentRegistryStore::try_global(cx);
3391 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
3392
3393 struct AgentMenuItem {
3394 id: AgentId,
3395 display_name: SharedString,
3396 }
3397
3398 let agent_items = agent_server_store
3399 .external_agents()
3400 .map(|agent_id| {
3401 let display_name = agent_server_store
3402 .agent_display_name(agent_id)
3403 .or_else(|| {
3404 registry_store_ref
3405 .as_ref()
3406 .and_then(|store| store.agent(agent_id))
3407 .map(|a| a.name().clone())
3408 })
3409 .unwrap_or_else(|| agent_id.0.clone());
3410 AgentMenuItem {
3411 id: agent_id.clone(),
3412 display_name,
3413 }
3414 })
3415 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3416 .collect::<Vec<_>>();
3417
3418 if !agent_items.is_empty() {
3419 menu = menu.separator().header("External Agents");
3420 }
3421 for item in &agent_items {
3422 let mut entry = ContextMenuEntry::new(item.display_name.clone());
3423
3424 let icon_path =
3425 agent_server_store.agent_icon(&item.id).or_else(|| {
3426 registry_store_ref
3427 .as_ref()
3428 .and_then(|store| store.agent(&item.id))
3429 .and_then(|a| a.icon_path().cloned())
3430 });
3431
3432 if let Some(icon_path) = icon_path {
3433 entry = entry.custom_icon_svg(icon_path);
3434 } else {
3435 entry = entry.icon(IconName::Sparkle);
3436 }
3437
3438 entry = entry
3439 .when(
3440 is_agent_selected(Agent::Custom {
3441 id: item.id.clone(),
3442 }),
3443 |this| {
3444 this.action(Box::new(NewExternalAgentThread {
3445 agent: None,
3446 }))
3447 },
3448 )
3449 .icon_color(Color::Muted)
3450 .disabled(is_via_collab)
3451 .handler({
3452 let workspace = workspace.clone();
3453 let agent_id = item.id.clone();
3454 move |window, cx| {
3455 if let Some(workspace) = workspace.upgrade() {
3456 workspace.update(cx, |workspace, cx| {
3457 if let Some(panel) =
3458 workspace.panel::<AgentPanel>(cx)
3459 {
3460 panel.update(cx, |panel, cx| {
3461 panel.new_agent_thread(
3462 Agent::Custom {
3463 id: agent_id.clone(),
3464 },
3465 window,
3466 cx,
3467 );
3468 });
3469 }
3470 });
3471 }
3472 }
3473 });
3474
3475 menu = menu.item(entry);
3476 }
3477
3478 menu
3479 })
3480 .separator()
3481 .item(
3482 ContextMenuEntry::new("Add More Agents")
3483 .icon(IconName::Plus)
3484 .icon_color(Color::Muted)
3485 .handler({
3486 move |window, cx| {
3487 window
3488 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
3489 }
3490 }),
3491 )
3492 }))
3493 })
3494 };
3495
3496 let is_thread_loading = self
3497 .active_conversation_view()
3498 .map(|thread| thread.read(cx).is_loading())
3499 .unwrap_or(false);
3500
3501 let has_custom_icon = selected_agent_custom_icon.is_some();
3502 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
3503 let selected_agent_builtin_icon = self.selected_agent.icon();
3504 let selected_agent_label_for_tooltip = selected_agent_label.clone();
3505
3506 let selected_agent = div()
3507 .id("selected_agent_icon")
3508 .when_some(selected_agent_custom_icon, |this, icon_path| {
3509 this.px_1()
3510 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
3511 })
3512 .when(!has_custom_icon, |this| {
3513 this.when_some(selected_agent_builtin_icon, |this, icon| {
3514 this.px_1().child(Icon::new(icon).color(Color::Muted))
3515 })
3516 })
3517 .tooltip(move |_, cx| {
3518 Tooltip::with_meta(
3519 selected_agent_label_for_tooltip.clone(),
3520 None,
3521 "Selected Agent",
3522 cx,
3523 )
3524 });
3525
3526 let selected_agent = if is_thread_loading {
3527 selected_agent
3528 .with_animation(
3529 "pulsating-icon",
3530 Animation::new(Duration::from_secs(1))
3531 .repeat()
3532 .with_easing(pulsating_between(0.2, 0.6)),
3533 |icon, delta| icon.opacity(delta),
3534 )
3535 .into_any_element()
3536 } else {
3537 selected_agent.into_any_element()
3538 };
3539
3540 let show_history_menu = self.has_history_for_selected_agent(cx);
3541 let has_v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
3542 let is_empty_state = !self.active_thread_has_messages(cx);
3543
3544 let is_in_history_or_config = matches!(
3545 &self.active_view,
3546 ActiveView::History { .. } | ActiveView::Configuration
3547 );
3548
3549 let is_full_screen = self.is_zoomed(window, cx);
3550
3551 let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config;
3552
3553 let base_container = h_flex()
3554 .id("agent-panel-toolbar")
3555 .h(Tab::container_height(cx))
3556 .max_w_full()
3557 .flex_none()
3558 .justify_between()
3559 .gap_2()
3560 .bg(cx.theme().colors().tab_bar_background)
3561 .border_b_1()
3562 .border_color(cx.theme().colors().border);
3563
3564 if use_v2_empty_toolbar {
3565 let (chevron_icon, icon_color, label_color) =
3566 if self.new_thread_menu_handle.is_deployed() {
3567 (IconName::ChevronUp, Color::Accent, Color::Accent)
3568 } else {
3569 (IconName::ChevronDown, Color::Muted, Color::Default)
3570 };
3571
3572 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
3573 Icon::from_external_svg(icon_path)
3574 .size(IconSize::Small)
3575 .color(icon_color)
3576 } else {
3577 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
3578 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
3579 };
3580
3581 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
3582 .start_icon(agent_icon)
3583 .color(label_color)
3584 .end_icon(
3585 Icon::new(chevron_icon)
3586 .color(icon_color)
3587 .size(IconSize::XSmall),
3588 );
3589
3590 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
3591 .trigger_with_tooltip(agent_selector_button, {
3592 move |_window, cx| {
3593 Tooltip::for_action_in(
3594 "New Thread…",
3595 &ToggleNewThreadMenu,
3596 &focus_handle,
3597 cx,
3598 )
3599 }
3600 })
3601 .menu({
3602 let builder = new_thread_menu_builder.clone();
3603 move |window, cx| builder(window, cx)
3604 })
3605 .with_handle(self.new_thread_menu_handle.clone())
3606 .anchor(Corner::TopLeft)
3607 .offset(gpui::Point {
3608 x: px(1.0),
3609 y: px(1.0),
3610 });
3611
3612 base_container
3613 .child(
3614 h_flex()
3615 .size_full()
3616 .gap(DynamicSpacing::Base04.rems(cx))
3617 .pl(DynamicSpacing::Base04.rems(cx))
3618 .child(agent_selector_menu)
3619 .when(
3620 has_visible_worktrees && self.project_has_git_repository(cx),
3621 |this| this.child(self.render_start_thread_in_selector(cx)),
3622 ),
3623 )
3624 .child(
3625 h_flex()
3626 .h_full()
3627 .flex_none()
3628 .gap_1()
3629 .pl_1()
3630 .pr_1()
3631 .when(show_history_menu && !has_v2_flag, |this| {
3632 this.child(self.render_recent_entries_menu(
3633 IconName::MenuAltTemp,
3634 Corner::TopRight,
3635 cx,
3636 ))
3637 })
3638 .when(is_full_screen, |this| {
3639 this.child(
3640 IconButton::new("disable-full-screen", IconName::Minimize)
3641 .icon_size(IconSize::Small)
3642 .tooltip(move |_, cx| {
3643 Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
3644 })
3645 .on_click({
3646 cx.listener(move |_, _, window, cx| {
3647 window.dispatch_action(ToggleZoom.boxed_clone(), cx);
3648 })
3649 }),
3650 )
3651 })
3652 .child(self.render_panel_options_menu(window, cx)),
3653 )
3654 .into_any_element()
3655 } else {
3656 let new_thread_menu = PopoverMenu::new("new_thread_menu")
3657 .trigger_with_tooltip(
3658 IconButton::new("new_thread_menu_btn", IconName::Plus)
3659 .icon_size(IconSize::Small),
3660 {
3661 move |_window, cx| {
3662 Tooltip::for_action_in(
3663 "New Thread\u{2026}",
3664 &ToggleNewThreadMenu,
3665 &focus_handle,
3666 cx,
3667 )
3668 }
3669 },
3670 )
3671 .anchor(Corner::TopRight)
3672 .with_handle(self.new_thread_menu_handle.clone())
3673 .menu(move |window, cx| new_thread_menu_builder(window, cx));
3674
3675 base_container
3676 .child(
3677 h_flex()
3678 .size_full()
3679 .gap(DynamicSpacing::Base04.rems(cx))
3680 .pl(DynamicSpacing::Base04.rems(cx))
3681 .child(match &self.active_view {
3682 ActiveView::History { .. } | ActiveView::Configuration => {
3683 self.render_toolbar_back_button(cx).into_any_element()
3684 }
3685 _ => selected_agent.into_any_element(),
3686 })
3687 .child(self.render_title_view(window, cx)),
3688 )
3689 .child(
3690 h_flex()
3691 .h_full()
3692 .flex_none()
3693 .gap_1()
3694 .pl_1()
3695 .pr_1()
3696 .child(new_thread_menu)
3697 .when(show_history_menu && !has_v2_flag, |this| {
3698 this.child(self.render_recent_entries_menu(
3699 IconName::MenuAltTemp,
3700 Corner::TopRight,
3701 cx,
3702 ))
3703 })
3704 .when(is_full_screen, |this| {
3705 this.child(
3706 IconButton::new("disable-full-screen", IconName::Minimize)
3707 .icon_size(IconSize::Small)
3708 .tooltip(move |_, cx| {
3709 Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
3710 })
3711 .on_click({
3712 cx.listener(move |_, _, window, cx| {
3713 window.dispatch_action(ToggleZoom.boxed_clone(), cx);
3714 })
3715 }),
3716 )
3717 })
3718 .child(self.render_panel_options_menu(window, cx)),
3719 )
3720 .into_any_element()
3721 }
3722 }
3723
3724 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3725 let status = self.worktree_creation_status.as_ref()?;
3726 match status {
3727 WorktreeCreationStatus::Creating => Some(
3728 h_flex()
3729 .absolute()
3730 .bottom_12()
3731 .w_full()
3732 .p_2()
3733 .gap_1()
3734 .justify_center()
3735 .bg(cx.theme().colors().editor_background)
3736 .child(
3737 Icon::new(IconName::LoadCircle)
3738 .size(IconSize::Small)
3739 .color(Color::Muted)
3740 .with_rotate_animation(3),
3741 )
3742 .child(
3743 Label::new("Creating Worktree…")
3744 .color(Color::Muted)
3745 .size(LabelSize::Small),
3746 )
3747 .into_any_element(),
3748 ),
3749 WorktreeCreationStatus::Error(message) => Some(
3750 Callout::new()
3751 .icon(IconName::Warning)
3752 .severity(Severity::Warning)
3753 .title(message.clone())
3754 .into_any_element(),
3755 ),
3756 }
3757 }
3758
3759 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
3760 if TrialEndUpsell::dismissed(cx) {
3761 return false;
3762 }
3763
3764 match &self.active_view {
3765 ActiveView::AgentThread { .. } => {
3766 if LanguageModelRegistry::global(cx)
3767 .read(cx)
3768 .default_model()
3769 .is_some_and(|model| {
3770 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3771 })
3772 {
3773 return false;
3774 }
3775 }
3776 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3777 return false;
3778 }
3779 }
3780
3781 let plan = self.user_store.read(cx).plan();
3782 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
3783
3784 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
3785 }
3786
3787 fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
3788 if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
3789 return false;
3790 }
3791
3792 let user_store = self.user_store.read(cx);
3793
3794 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
3795 && user_store
3796 .subscription_period()
3797 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
3798 .is_some_and(|date| date < chrono::Utc::now())
3799 {
3800 OnboardingUpsell::set_dismissed(true, cx);
3801 self.on_boarding_upsell_dismissed
3802 .store(true, Ordering::Release);
3803 return false;
3804 }
3805
3806 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
3807 .visible_providers()
3808 .iter()
3809 .any(|provider| {
3810 provider.is_authenticated(cx)
3811 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3812 });
3813
3814 match &self.active_view {
3815 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3816 false
3817 }
3818 ActiveView::AgentThread {
3819 conversation_view, ..
3820 } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
3821 ActiveView::AgentThread { conversation_view } => {
3822 let history_is_empty = conversation_view
3823 .read(cx)
3824 .history()
3825 .is_none_or(|h| h.read(cx).is_empty());
3826 history_is_empty || !has_configured_non_zed_providers
3827 }
3828 }
3829 }
3830
3831 fn render_onboarding(
3832 &self,
3833 _window: &mut Window,
3834 cx: &mut Context<Self>,
3835 ) -> Option<impl IntoElement> {
3836 if !self.should_render_onboarding(cx) {
3837 return None;
3838 }
3839
3840 Some(div().child(self.onboarding.clone()))
3841 }
3842
3843 fn render_trial_end_upsell(
3844 &self,
3845 _window: &mut Window,
3846 cx: &mut Context<Self>,
3847 ) -> Option<impl IntoElement> {
3848 if !self.should_render_trial_end_upsell(cx) {
3849 return None;
3850 }
3851
3852 Some(
3853 v_flex()
3854 .absolute()
3855 .inset_0()
3856 .size_full()
3857 .bg(cx.theme().colors().panel_background)
3858 .opacity(0.85)
3859 .block_mouse_except_scroll()
3860 .child(EndTrialUpsell::new(Arc::new({
3861 let this = cx.entity();
3862 move |_, cx| {
3863 this.update(cx, |_this, cx| {
3864 TrialEndUpsell::set_dismissed(true, cx);
3865 cx.notify();
3866 });
3867 }
3868 }))),
3869 )
3870 }
3871
3872 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
3873 let is_local = self.project.read(cx).is_local();
3874 div()
3875 .invisible()
3876 .absolute()
3877 .top_0()
3878 .right_0()
3879 .bottom_0()
3880 .left_0()
3881 .bg(cx.theme().colors().drop_target_background)
3882 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
3883 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
3884 .when(is_local, |this| {
3885 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
3886 })
3887 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
3888 let item = tab.pane.read(cx).item_for_index(tab.ix);
3889 let project_paths = item
3890 .and_then(|item| item.project_path(cx))
3891 .into_iter()
3892 .collect::<Vec<_>>();
3893 this.handle_drop(project_paths, vec![], window, cx);
3894 }))
3895 .on_drop(
3896 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
3897 let project_paths = selection
3898 .items()
3899 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
3900 .collect::<Vec<_>>();
3901 this.handle_drop(project_paths, vec![], window, cx);
3902 }),
3903 )
3904 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
3905 let tasks = paths
3906 .paths()
3907 .iter()
3908 .map(|path| {
3909 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
3910 })
3911 .collect::<Vec<_>>();
3912 cx.spawn_in(window, async move |this, cx| {
3913 let mut paths = vec![];
3914 let mut added_worktrees = vec![];
3915 let opened_paths = futures::future::join_all(tasks).await;
3916 for entry in opened_paths {
3917 if let Some((worktree, project_path)) = entry.log_err() {
3918 added_worktrees.push(worktree);
3919 paths.push(project_path);
3920 }
3921 }
3922 this.update_in(cx, |this, window, cx| {
3923 this.handle_drop(paths, added_worktrees, window, cx);
3924 })
3925 .ok();
3926 })
3927 .detach();
3928 }))
3929 }
3930
3931 fn handle_drop(
3932 &mut self,
3933 paths: Vec<ProjectPath>,
3934 added_worktrees: Vec<Entity<Worktree>>,
3935 window: &mut Window,
3936 cx: &mut Context<Self>,
3937 ) {
3938 match &self.active_view {
3939 ActiveView::AgentThread { conversation_view } => {
3940 conversation_view.update(cx, |conversation_view, cx| {
3941 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
3942 });
3943 }
3944 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3945 }
3946 }
3947
3948 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
3949 if !self.show_trust_workspace_message {
3950 return None;
3951 }
3952
3953 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
3954
3955 Some(
3956 Callout::new()
3957 .icon(IconName::Warning)
3958 .severity(Severity::Warning)
3959 .border_position(ui::BorderPosition::Bottom)
3960 .title("You're in Restricted Mode")
3961 .description(description)
3962 .actions_slot(
3963 Button::new("open-trust-modal", "Configure Project Trust")
3964 .label_size(LabelSize::Small)
3965 .style(ButtonStyle::Outlined)
3966 .on_click({
3967 cx.listener(move |this, _, window, cx| {
3968 this.workspace
3969 .update(cx, |workspace, cx| {
3970 workspace
3971 .show_worktree_trust_security_modal(true, window, cx)
3972 })
3973 .log_err();
3974 })
3975 }),
3976 ),
3977 )
3978 }
3979
3980 fn key_context(&self) -> KeyContext {
3981 let mut key_context = KeyContext::new_with_defaults();
3982 key_context.add("AgentPanel");
3983 match &self.active_view {
3984 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
3985 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3986 }
3987 key_context
3988 }
3989}
3990
3991impl Render for AgentPanel {
3992 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3993 // WARNING: Changes to this element hierarchy can have
3994 // non-obvious implications to the layout of children.
3995 //
3996 // If you need to change it, please confirm:
3997 // - The message editor expands (cmd-option-esc) correctly
3998 // - When expanded, the buttons at the bottom of the panel are displayed correctly
3999 // - Font size works as expected and can be changed with cmd-+/cmd-
4000 // - Scrolling in all views works as expected
4001 // - Files can be dropped into the panel
4002 let content = v_flex()
4003 .relative()
4004 .size_full()
4005 .justify_between()
4006 .key_context(self.key_context())
4007 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4008 this.new_thread(action, window, cx);
4009 }))
4010 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4011 this.open_history(window, cx);
4012 }))
4013 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4014 this.open_configuration(window, cx);
4015 }))
4016 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4017 .on_action(cx.listener(Self::deploy_rules_library))
4018 .on_action(cx.listener(Self::go_back))
4019 .on_action(cx.listener(Self::toggle_navigation_menu))
4020 .on_action(cx.listener(Self::toggle_options_menu))
4021 .on_action(cx.listener(Self::increase_font_size))
4022 .on_action(cx.listener(Self::decrease_font_size))
4023 .on_action(cx.listener(Self::reset_font_size))
4024 .on_action(cx.listener(Self::toggle_zoom))
4025 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4026 if let Some(conversation_view) = this.active_conversation_view() {
4027 conversation_view.update(cx, |conversation_view, cx| {
4028 conversation_view.reauthenticate(window, cx)
4029 })
4030 }
4031 }))
4032 .child(self.render_toolbar(window, cx))
4033 .children(self.render_workspace_trust_message(cx))
4034 .children(self.render_onboarding(window, cx))
4035 .map(|parent| match &self.active_view {
4036 ActiveView::Uninitialized => parent,
4037 ActiveView::AgentThread {
4038 conversation_view, ..
4039 } => parent
4040 .child(conversation_view.clone())
4041 .child(self.render_drag_target(cx)),
4042 ActiveView::History { view } => parent.child(view.clone()),
4043 ActiveView::Configuration => parent.children(self.configuration.clone()),
4044 })
4045 .children(self.render_worktree_creation_status(cx))
4046 .children(self.render_trial_end_upsell(window, cx));
4047
4048 match self.active_view.which_font_size_used() {
4049 WhichFontSize::AgentFont => {
4050 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4051 .size_full()
4052 .child(content)
4053 .into_any()
4054 }
4055 _ => content.into_any(),
4056 }
4057 }
4058}
4059
4060struct PromptLibraryInlineAssist {
4061 workspace: WeakEntity<Workspace>,
4062}
4063
4064impl PromptLibraryInlineAssist {
4065 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4066 Self { workspace }
4067 }
4068}
4069
4070impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4071 fn assist(
4072 &self,
4073 prompt_editor: &Entity<Editor>,
4074 initial_prompt: Option<String>,
4075 window: &mut Window,
4076 cx: &mut Context<RulesLibrary>,
4077 ) {
4078 InlineAssistant::update_global(cx, |assistant, cx| {
4079 let Some(workspace) = self.workspace.upgrade() else {
4080 return;
4081 };
4082 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4083 return;
4084 };
4085 let history = panel
4086 .read(cx)
4087 .connection_store()
4088 .read(cx)
4089 .entry(&crate::Agent::NativeAgent)
4090 .and_then(|s| s.read(cx).history())
4091 .map(|h| h.downgrade());
4092 let project = workspace.read(cx).project().downgrade();
4093 let panel = panel.read(cx);
4094 let thread_store = panel.thread_store().clone();
4095 assistant.assist(
4096 prompt_editor,
4097 self.workspace.clone(),
4098 project,
4099 thread_store,
4100 None,
4101 history,
4102 initial_prompt,
4103 window,
4104 cx,
4105 );
4106 })
4107 }
4108
4109 fn focus_agent_panel(
4110 &self,
4111 workspace: &mut Workspace,
4112 window: &mut Window,
4113 cx: &mut Context<Workspace>,
4114 ) -> bool {
4115 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4116 }
4117}
4118
4119struct OnboardingUpsell;
4120
4121impl Dismissable for OnboardingUpsell {
4122 const KEY: &'static str = "dismissed-trial-upsell";
4123}
4124
4125struct TrialEndUpsell;
4126
4127impl Dismissable for TrialEndUpsell {
4128 const KEY: &'static str = "dismissed-trial-end-upsell";
4129}
4130
4131/// Test-only helper methods
4132#[cfg(any(test, feature = "test-support"))]
4133impl AgentPanel {
4134 pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4135 Self::new(workspace, None, window, cx)
4136 }
4137
4138 /// Opens an external thread using an arbitrary AgentServer.
4139 ///
4140 /// This is a test-only helper that allows visual tests and integration tests
4141 /// to inject a stub server without modifying production code paths.
4142 /// Not compiled into production builds.
4143 pub fn open_external_thread_with_server(
4144 &mut self,
4145 server: Rc<dyn AgentServer>,
4146 window: &mut Window,
4147 cx: &mut Context<Self>,
4148 ) {
4149 let workspace = self.workspace.clone();
4150 let project = self.project.clone();
4151
4152 let ext_agent = Agent::Custom {
4153 id: server.agent_id(),
4154 };
4155
4156 self.create_agent_thread(
4157 server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
4158 );
4159 }
4160
4161 /// Returns the currently active thread view, if any.
4162 ///
4163 /// This is a test-only accessor that exposes the private `active_thread_view()`
4164 /// method for test assertions. Not compiled into production builds.
4165 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4166 self.active_conversation_view()
4167 }
4168
4169 /// Sets the start_thread_in value directly, bypassing validation.
4170 ///
4171 /// This is a test-only helper for visual tests that need to show specific
4172 /// start_thread_in states without requiring a real git repository.
4173 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4174 self.start_thread_in = target;
4175 cx.notify();
4176 }
4177
4178 /// Returns the current worktree creation status.
4179 ///
4180 /// This is a test-only helper for visual tests.
4181 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4182 self.worktree_creation_status.as_ref()
4183 }
4184
4185 /// Sets the worktree creation status directly.
4186 ///
4187 /// This is a test-only helper for visual tests that need to show the
4188 /// "Creating worktree…" spinner or error banners.
4189 pub fn set_worktree_creation_status_for_tests(
4190 &mut self,
4191 status: Option<WorktreeCreationStatus>,
4192 cx: &mut Context<Self>,
4193 ) {
4194 self.worktree_creation_status = status;
4195 cx.notify();
4196 }
4197
4198 /// Opens the history view.
4199 ///
4200 /// This is a test-only helper that exposes the private `open_history()`
4201 /// method for visual tests.
4202 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4203 self.open_history(window, cx);
4204 }
4205
4206 /// Opens the start_thread_in selector popover menu.
4207 ///
4208 /// This is a test-only helper for visual tests.
4209 pub fn open_start_thread_in_menu_for_tests(
4210 &mut self,
4211 window: &mut Window,
4212 cx: &mut Context<Self>,
4213 ) {
4214 self.start_thread_in_menu_handle.show(window, cx);
4215 }
4216
4217 /// Dismisses the start_thread_in dropdown menu.
4218 ///
4219 /// This is a test-only helper for visual tests.
4220 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4221 self.start_thread_in_menu_handle.hide(cx);
4222 }
4223}
4224
4225#[cfg(test)]
4226mod tests {
4227 use super::*;
4228 use crate::conversation_view::tests::{StubAgentServer, init_test};
4229 use crate::test_support::{
4230 active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4231 send_message,
4232 };
4233 use acp_thread::{StubAgentConnection, ThreadStatus};
4234 use agent_servers::CODEX_ID;
4235 use feature_flags::FeatureFlagAppExt;
4236 use fs::FakeFs;
4237 use gpui::{TestAppContext, VisualTestContext};
4238 use project::Project;
4239 use serde_json::json;
4240 use std::path::Path;
4241 use std::time::Instant;
4242 use workspace::MultiWorkspace;
4243
4244 #[gpui::test]
4245 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4246 init_test(cx);
4247 cx.update(|cx| {
4248 cx.update_flags(true, vec!["agent-v2".to_string()]);
4249 agent::ThreadStore::init_global(cx);
4250 language_model::LanguageModelRegistry::test(cx);
4251 });
4252
4253 // --- Create a MultiWorkspace window with two workspaces ---
4254 let fs = FakeFs::new(cx.executor());
4255 let project_a = Project::test(fs.clone(), [], cx).await;
4256 let project_b = Project::test(fs, [], cx).await;
4257
4258 let multi_workspace =
4259 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4260
4261 let workspace_a = multi_workspace
4262 .read_with(cx, |multi_workspace, _cx| {
4263 multi_workspace.workspace().clone()
4264 })
4265 .unwrap();
4266
4267 let workspace_b = multi_workspace
4268 .update(cx, |multi_workspace, window, cx| {
4269 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4270 })
4271 .unwrap();
4272
4273 workspace_a.update(cx, |workspace, _cx| {
4274 workspace.set_random_database_id();
4275 });
4276 workspace_b.update(cx, |workspace, _cx| {
4277 workspace.set_random_database_id();
4278 });
4279
4280 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4281
4282 // --- Set up workspace A: with an active thread ---
4283 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4284 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4285 });
4286
4287 panel_a.update_in(cx, |panel, window, cx| {
4288 panel.open_external_thread_with_server(
4289 Rc::new(StubAgentServer::default_response()),
4290 window,
4291 cx,
4292 );
4293 });
4294
4295 cx.run_until_parked();
4296
4297 panel_a.read_with(cx, |panel, cx| {
4298 assert!(
4299 panel.active_agent_thread(cx).is_some(),
4300 "workspace A should have an active thread after connection"
4301 );
4302 });
4303
4304 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4305
4306 // --- Set up workspace B: ClaudeCode, no active thread ---
4307 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4308 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4309 });
4310
4311 panel_b.update(cx, |panel, _cx| {
4312 panel.selected_agent = Agent::Custom {
4313 id: "claude-acp".into(),
4314 };
4315 });
4316
4317 // --- Serialize both panels ---
4318 panel_a.update(cx, |panel, cx| panel.serialize(cx));
4319 panel_b.update(cx, |panel, cx| panel.serialize(cx));
4320 cx.run_until_parked();
4321
4322 // --- Load fresh panels for each workspace and verify independent state ---
4323 let async_cx = cx.update(|window, cx| window.to_async(cx));
4324 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
4325 .await
4326 .expect("panel A load should succeed");
4327 cx.run_until_parked();
4328
4329 let async_cx = cx.update(|window, cx| window.to_async(cx));
4330 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
4331 .await
4332 .expect("panel B load should succeed");
4333 cx.run_until_parked();
4334
4335 // Workspace A should restore its thread and agent type
4336 loaded_a.read_with(cx, |panel, _cx| {
4337 assert_eq!(
4338 panel.selected_agent, agent_type_a,
4339 "workspace A agent type should be restored"
4340 );
4341 assert!(
4342 panel.active_conversation_view().is_some(),
4343 "workspace A should have its active thread restored"
4344 );
4345 });
4346
4347 // Workspace B should restore its own agent type, with no thread
4348 loaded_b.read_with(cx, |panel, _cx| {
4349 assert_eq!(
4350 panel.selected_agent,
4351 Agent::Custom {
4352 id: "claude-acp".into()
4353 },
4354 "workspace B agent type should be restored"
4355 );
4356 assert!(
4357 panel.active_conversation_view().is_none(),
4358 "workspace B should have no active thread"
4359 );
4360 });
4361 }
4362
4363 /// Extracts the text from a Text content block, panicking if it's not Text.
4364 fn expect_text_block(block: &acp::ContentBlock) -> &str {
4365 match block {
4366 acp::ContentBlock::Text(t) => t.text.as_str(),
4367 other => panic!("expected Text block, got {:?}", other),
4368 }
4369 }
4370
4371 /// Extracts the (text_content, uri) from a Resource content block, panicking
4372 /// if it's not a TextResourceContents resource.
4373 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
4374 match block {
4375 acp::ContentBlock::Resource(r) => match &r.resource {
4376 acp::EmbeddedResourceResource::TextResourceContents(t) => {
4377 (t.text.as_str(), t.uri.as_str())
4378 }
4379 other => panic!("expected TextResourceContents, got {:?}", other),
4380 },
4381 other => panic!("expected Resource block, got {:?}", other),
4382 }
4383 }
4384
4385 #[test]
4386 fn test_build_conflict_resolution_prompt_single_conflict() {
4387 let conflicts = vec![ConflictContent {
4388 file_path: "src/main.rs".to_string(),
4389 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
4390 .to_string(),
4391 ours_branch_name: "HEAD".to_string(),
4392 theirs_branch_name: "feature".to_string(),
4393 }];
4394
4395 let blocks = build_conflict_resolution_prompt(&conflicts);
4396 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
4397 assert_eq!(
4398 blocks.len(),
4399 4,
4400 "expected 2 text + 1 resource link + 1 resource block"
4401 );
4402
4403 let intro_text = expect_text_block(&blocks[0]);
4404 assert!(
4405 intro_text.contains("Please resolve the following merge conflict in"),
4406 "prompt should include single-conflict intro text"
4407 );
4408
4409 match &blocks[1] {
4410 acp::ContentBlock::ResourceLink(link) => {
4411 assert!(
4412 link.uri.contains("file://"),
4413 "resource link URI should use file scheme"
4414 );
4415 assert!(
4416 link.uri.contains("main.rs"),
4417 "resource link URI should reference file path"
4418 );
4419 }
4420 other => panic!("expected ResourceLink block, got {:?}", other),
4421 }
4422
4423 let body_text = expect_text_block(&blocks[2]);
4424 assert!(
4425 body_text.contains("`HEAD` (ours)"),
4426 "prompt should mention ours branch"
4427 );
4428 assert!(
4429 body_text.contains("`feature` (theirs)"),
4430 "prompt should mention theirs branch"
4431 );
4432 assert!(
4433 body_text.contains("editing the file directly"),
4434 "prompt should instruct the agent to edit the file"
4435 );
4436
4437 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
4438 assert!(
4439 resource_text.contains("<<<<<<< HEAD"),
4440 "resource should contain the conflict text"
4441 );
4442 assert!(
4443 resource_uri.contains("merge-conflict"),
4444 "resource URI should use the merge-conflict scheme"
4445 );
4446 assert!(
4447 resource_uri.contains("main.rs"),
4448 "resource URI should reference the file path"
4449 );
4450 }
4451
4452 #[test]
4453 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
4454 let conflicts = vec![
4455 ConflictContent {
4456 file_path: "src/lib.rs".to_string(),
4457 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
4458 .to_string(),
4459 ours_branch_name: "main".to_string(),
4460 theirs_branch_name: "dev".to_string(),
4461 },
4462 ConflictContent {
4463 file_path: "src/lib.rs".to_string(),
4464 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
4465 .to_string(),
4466 ours_branch_name: "main".to_string(),
4467 theirs_branch_name: "dev".to_string(),
4468 },
4469 ];
4470
4471 let blocks = build_conflict_resolution_prompt(&conflicts);
4472 // 1 Text instruction + 2 Resource blocks
4473 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
4474
4475 let text = expect_text_block(&blocks[0]);
4476 assert!(
4477 text.contains("all 2 merge conflicts"),
4478 "prompt should mention the total count"
4479 );
4480 assert!(
4481 text.contains("`main` (ours)"),
4482 "prompt should mention ours branch"
4483 );
4484 assert!(
4485 text.contains("`dev` (theirs)"),
4486 "prompt should mention theirs branch"
4487 );
4488 // Single file, so "file" not "files"
4489 assert!(
4490 text.contains("file directly"),
4491 "single file should use singular 'file'"
4492 );
4493
4494 let (resource_a, _) = expect_resource_block(&blocks[1]);
4495 let (resource_b, _) = expect_resource_block(&blocks[2]);
4496 assert!(
4497 resource_a.contains("fn a()"),
4498 "first resource should contain first conflict"
4499 );
4500 assert!(
4501 resource_b.contains("fn b()"),
4502 "second resource should contain second conflict"
4503 );
4504 }
4505
4506 #[test]
4507 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
4508 let conflicts = vec![
4509 ConflictContent {
4510 file_path: "src/a.rs".to_string(),
4511 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
4512 ours_branch_name: "main".to_string(),
4513 theirs_branch_name: "dev".to_string(),
4514 },
4515 ConflictContent {
4516 file_path: "src/b.rs".to_string(),
4517 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
4518 ours_branch_name: "main".to_string(),
4519 theirs_branch_name: "dev".to_string(),
4520 },
4521 ];
4522
4523 let blocks = build_conflict_resolution_prompt(&conflicts);
4524 // 1 Text instruction + 2 Resource blocks
4525 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
4526
4527 let text = expect_text_block(&blocks[0]);
4528 assert!(
4529 text.contains("files directly"),
4530 "multiple files should use plural 'files'"
4531 );
4532
4533 let (_, uri_a) = expect_resource_block(&blocks[1]);
4534 let (_, uri_b) = expect_resource_block(&blocks[2]);
4535 assert!(
4536 uri_a.contains("a.rs"),
4537 "first resource URI should reference a.rs"
4538 );
4539 assert!(
4540 uri_b.contains("b.rs"),
4541 "second resource URI should reference b.rs"
4542 );
4543 }
4544
4545 #[test]
4546 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
4547 let file_paths = vec![
4548 "src/main.rs".to_string(),
4549 "src/lib.rs".to_string(),
4550 "tests/integration.rs".to_string(),
4551 ];
4552
4553 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
4554 // 1 instruction Text block + (ResourceLink + newline Text) per file
4555 assert_eq!(
4556 blocks.len(),
4557 1 + (file_paths.len() * 2),
4558 "expected instruction text plus resource links and separators"
4559 );
4560
4561 let text = expect_text_block(&blocks[0]);
4562 assert!(
4563 text.contains("unresolved merge conflicts"),
4564 "prompt should describe the task"
4565 );
4566 assert!(
4567 text.contains("conflict markers"),
4568 "prompt should mention conflict markers"
4569 );
4570
4571 for (index, path) in file_paths.iter().enumerate() {
4572 let link_index = 1 + (index * 2);
4573 let newline_index = link_index + 1;
4574
4575 match &blocks[link_index] {
4576 acp::ContentBlock::ResourceLink(link) => {
4577 assert!(
4578 link.uri.contains("file://"),
4579 "resource link URI should use file scheme"
4580 );
4581 assert!(
4582 link.uri.contains(path),
4583 "resource link URI should reference file path: {path}"
4584 );
4585 }
4586 other => panic!(
4587 "expected ResourceLink block at index {}, got {:?}",
4588 link_index, other
4589 ),
4590 }
4591
4592 let separator = expect_text_block(&blocks[newline_index]);
4593 assert_eq!(
4594 separator, "\n",
4595 "expected newline separator after each file"
4596 );
4597 }
4598 }
4599
4600 #[test]
4601 fn test_build_conflict_resolution_prompt_empty_conflicts() {
4602 let blocks = build_conflict_resolution_prompt(&[]);
4603 assert!(
4604 blocks.is_empty(),
4605 "empty conflicts should produce no blocks, got {} blocks",
4606 blocks.len()
4607 );
4608 }
4609
4610 #[test]
4611 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
4612 let blocks = build_conflicted_files_resolution_prompt(&[]);
4613 assert!(
4614 blocks.is_empty(),
4615 "empty paths should produce no blocks, got {} blocks",
4616 blocks.len()
4617 );
4618 }
4619
4620 #[test]
4621 fn test_conflict_resource_block_structure() {
4622 let conflict = ConflictContent {
4623 file_path: "src/utils.rs".to_string(),
4624 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
4625 ours_branch_name: "HEAD".to_string(),
4626 theirs_branch_name: "branch".to_string(),
4627 };
4628
4629 let block = conflict_resource_block(&conflict);
4630 let (text, uri) = expect_resource_block(&block);
4631
4632 assert_eq!(
4633 text, conflict.conflict_text,
4634 "resource text should be the raw conflict"
4635 );
4636 assert!(
4637 uri.starts_with("zed:///agent/merge-conflict"),
4638 "URI should use the zed merge-conflict scheme, got: {uri}"
4639 );
4640 assert!(uri.contains("utils.rs"), "URI should encode the file path");
4641 }
4642
4643 fn open_generating_thread_with_loadable_connection(
4644 panel: &Entity<AgentPanel>,
4645 connection: &StubAgentConnection,
4646 cx: &mut VisualTestContext,
4647 ) -> acp::SessionId {
4648 open_thread_with_custom_connection(panel, connection.clone(), cx);
4649 let session_id = active_session_id(panel, cx);
4650 send_message(panel, cx);
4651 cx.update(|_, cx| {
4652 connection.send_update(
4653 session_id.clone(),
4654 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
4655 cx,
4656 );
4657 });
4658 cx.run_until_parked();
4659 session_id
4660 }
4661
4662 fn open_idle_thread_with_non_loadable_connection(
4663 panel: &Entity<AgentPanel>,
4664 connection: &StubAgentConnection,
4665 cx: &mut VisualTestContext,
4666 ) -> acp::SessionId {
4667 open_thread_with_custom_connection(panel, connection.clone(), cx);
4668 let session_id = active_session_id(panel, cx);
4669
4670 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4671 acp::ContentChunk::new("done".into()),
4672 )]);
4673 send_message(panel, cx);
4674
4675 session_id
4676 }
4677
4678 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
4679 init_test(cx);
4680 cx.update(|cx| {
4681 cx.update_flags(true, vec!["agent-v2".to_string()]);
4682 agent::ThreadStore::init_global(cx);
4683 language_model::LanguageModelRegistry::test(cx);
4684 });
4685
4686 let fs = FakeFs::new(cx.executor());
4687 let project = Project::test(fs.clone(), [], cx).await;
4688
4689 let multi_workspace =
4690 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4691
4692 let workspace = multi_workspace
4693 .read_with(cx, |mw, _cx| mw.workspace().clone())
4694 .unwrap();
4695
4696 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
4697
4698 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
4699 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4700 });
4701
4702 (panel, cx)
4703 }
4704
4705 #[gpui::test]
4706 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
4707 let (panel, mut cx) = setup_panel(cx).await;
4708
4709 let connection_a = StubAgentConnection::new();
4710 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
4711 send_message(&panel, &mut cx);
4712
4713 let session_id_a = active_session_id(&panel, &cx);
4714
4715 // Send a chunk to keep thread A generating (don't end the turn).
4716 cx.update(|_, cx| {
4717 connection_a.send_update(
4718 session_id_a.clone(),
4719 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4720 cx,
4721 );
4722 });
4723 cx.run_until_parked();
4724
4725 // Verify thread A is generating.
4726 panel.read_with(&cx, |panel, cx| {
4727 let thread = panel.active_agent_thread(cx).unwrap();
4728 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
4729 assert!(panel.background_threads.is_empty());
4730 });
4731
4732 // Open a new thread B — thread A should be retained in background.
4733 let connection_b = StubAgentConnection::new();
4734 open_thread_with_connection(&panel, connection_b, &mut cx);
4735
4736 panel.read_with(&cx, |panel, _cx| {
4737 assert_eq!(
4738 panel.background_threads.len(),
4739 1,
4740 "Running thread A should be retained in background_views"
4741 );
4742 assert!(
4743 panel.background_threads.contains_key(&session_id_a),
4744 "Background view should be keyed by thread A's session ID"
4745 );
4746 });
4747 }
4748
4749 #[gpui::test]
4750 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
4751 let (panel, mut cx) = setup_panel(cx).await;
4752
4753 let connection_a = StubAgentConnection::new();
4754 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4755 acp::ContentChunk::new("Response".into()),
4756 )]);
4757 open_thread_with_connection(&panel, connection_a, &mut cx);
4758 send_message(&panel, &mut cx);
4759
4760 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
4761 panel.active_conversation_view().unwrap().downgrade()
4762 });
4763 let session_id_a = active_session_id(&panel, &cx);
4764
4765 // Thread A should be idle (auto-completed via set_next_prompt_updates).
4766 panel.read_with(&cx, |panel, cx| {
4767 let thread = panel.active_agent_thread(cx).unwrap();
4768 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
4769 });
4770
4771 // Open a new thread B — thread A should be retained because it is not loadable.
4772 let connection_b = StubAgentConnection::new();
4773 open_thread_with_connection(&panel, connection_b, &mut cx);
4774
4775 panel.read_with(&cx, |panel, _cx| {
4776 assert_eq!(
4777 panel.background_threads.len(),
4778 1,
4779 "Idle non-loadable thread A should be retained in background_views"
4780 );
4781 assert!(
4782 panel.background_threads.contains_key(&session_id_a),
4783 "Background view should be keyed by thread A's session ID"
4784 );
4785 });
4786
4787 assert!(
4788 weak_view_a.upgrade().is_some(),
4789 "Idle non-loadable ConnectionView should still be retained"
4790 );
4791 }
4792
4793 #[gpui::test]
4794 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
4795 let (panel, mut cx) = setup_panel(cx).await;
4796
4797 let connection_a = StubAgentConnection::new();
4798 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
4799 send_message(&panel, &mut cx);
4800
4801 let session_id_a = active_session_id(&panel, &cx);
4802
4803 // Keep thread A generating.
4804 cx.update(|_, cx| {
4805 connection_a.send_update(
4806 session_id_a.clone(),
4807 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4808 cx,
4809 );
4810 });
4811 cx.run_until_parked();
4812
4813 // Open thread B — thread A goes to background.
4814 let connection_b = StubAgentConnection::new();
4815 open_thread_with_connection(&panel, connection_b, &mut cx);
4816
4817 let session_id_b = active_session_id(&panel, &cx);
4818
4819 panel.read_with(&cx, |panel, _cx| {
4820 assert_eq!(panel.background_threads.len(), 1);
4821 assert!(panel.background_threads.contains_key(&session_id_a));
4822 });
4823
4824 // Load thread A back via load_agent_thread — should promote from background.
4825 panel.update_in(&mut cx, |panel, window, cx| {
4826 panel.load_agent_thread(
4827 panel.selected_agent().expect("selected agent must be set"),
4828 session_id_a.clone(),
4829 None,
4830 None,
4831 true,
4832 window,
4833 cx,
4834 );
4835 });
4836
4837 // Thread A should now be the active view, promoted from background.
4838 let active_session = active_session_id(&panel, &cx);
4839 assert_eq!(
4840 active_session, session_id_a,
4841 "Thread A should be the active thread after promotion"
4842 );
4843
4844 panel.read_with(&cx, |panel, _cx| {
4845 assert!(
4846 !panel.background_threads.contains_key(&session_id_a),
4847 "Promoted thread A should no longer be in background_views"
4848 );
4849 assert!(
4850 panel.background_threads.contains_key(&session_id_b),
4851 "Thread B (idle, non-loadable) should remain retained in background_views"
4852 );
4853 });
4854 }
4855
4856 #[gpui::test]
4857 async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
4858 cx: &mut TestAppContext,
4859 ) {
4860 let (panel, mut cx) = setup_panel(cx).await;
4861 let connection = StubAgentConnection::new()
4862 .with_supports_load_session(true)
4863 .with_agent_id("loadable-stub".into())
4864 .with_telemetry_id("loadable-stub".into());
4865 let mut session_ids = Vec::new();
4866
4867 for _ in 0..7 {
4868 session_ids.push(open_generating_thread_with_loadable_connection(
4869 &panel,
4870 &connection,
4871 &mut cx,
4872 ));
4873 }
4874
4875 let base_time = Instant::now();
4876
4877 for session_id in session_ids.iter().take(6) {
4878 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4879 }
4880 cx.run_until_parked();
4881
4882 panel.update(&mut cx, |panel, cx| {
4883 for (index, session_id) in session_ids.iter().take(6).enumerate() {
4884 let conversation_view = panel
4885 .background_threads
4886 .get(session_id)
4887 .expect("background thread should exist")
4888 .clone();
4889 conversation_view.update(cx, |view, cx| {
4890 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
4891 });
4892 }
4893 panel.cleanup_background_threads(cx);
4894 });
4895
4896 panel.read_with(&cx, |panel, _cx| {
4897 assert_eq!(
4898 panel.background_threads.len(),
4899 5,
4900 "cleanup should keep at most five idle loadable background threads"
4901 );
4902 assert!(
4903 !panel.background_threads.contains_key(&session_ids[0]),
4904 "oldest idle loadable background thread should be removed"
4905 );
4906 for session_id in &session_ids[1..6] {
4907 assert!(
4908 panel.background_threads.contains_key(session_id),
4909 "more recent idle loadable background threads should be retained"
4910 );
4911 }
4912 assert!(
4913 !panel.background_threads.contains_key(&session_ids[6]),
4914 "the active thread should not also be stored as a background thread"
4915 );
4916 });
4917 }
4918
4919 #[gpui::test]
4920 async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
4921 cx: &mut TestAppContext,
4922 ) {
4923 let (panel, mut cx) = setup_panel(cx).await;
4924
4925 let non_loadable_connection = StubAgentConnection::new();
4926 let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
4927 &panel,
4928 &non_loadable_connection,
4929 &mut cx,
4930 );
4931
4932 let loadable_connection = StubAgentConnection::new()
4933 .with_supports_load_session(true)
4934 .with_agent_id("loadable-stub".into())
4935 .with_telemetry_id("loadable-stub".into());
4936 let mut loadable_session_ids = Vec::new();
4937
4938 for _ in 0..7 {
4939 loadable_session_ids.push(open_generating_thread_with_loadable_connection(
4940 &panel,
4941 &loadable_connection,
4942 &mut cx,
4943 ));
4944 }
4945
4946 let base_time = Instant::now();
4947
4948 for session_id in loadable_session_ids.iter().take(6) {
4949 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4950 }
4951 cx.run_until_parked();
4952
4953 panel.update(&mut cx, |panel, cx| {
4954 for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
4955 let conversation_view = panel
4956 .background_threads
4957 .get(session_id)
4958 .expect("background thread should exist")
4959 .clone();
4960 conversation_view.update(cx, |view, cx| {
4961 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
4962 });
4963 }
4964 panel.cleanup_background_threads(cx);
4965 });
4966
4967 panel.read_with(&cx, |panel, _cx| {
4968 assert_eq!(
4969 panel.background_threads.len(),
4970 6,
4971 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
4972 );
4973 assert!(
4974 panel
4975 .background_threads
4976 .contains_key(&non_loadable_session_id),
4977 "idle non-loadable background threads should not be cleanup candidates"
4978 );
4979 assert!(
4980 !panel
4981 .background_threads
4982 .contains_key(&loadable_session_ids[0]),
4983 "oldest idle loadable background thread should still be removed"
4984 );
4985 for session_id in &loadable_session_ids[1..6] {
4986 assert!(
4987 panel.background_threads.contains_key(session_id),
4988 "more recent idle loadable background threads should be retained"
4989 );
4990 }
4991 assert!(
4992 !panel
4993 .background_threads
4994 .contains_key(&loadable_session_ids[6]),
4995 "the active loadable thread should not also be stored as a background thread"
4996 );
4997 });
4998 }
4999
5000 #[gpui::test]
5001 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5002 init_test(cx);
5003 cx.update(|cx| {
5004 cx.update_flags(true, vec!["agent-v2".to_string()]);
5005 agent::ThreadStore::init_global(cx);
5006 language_model::LanguageModelRegistry::test(cx);
5007 });
5008
5009 let fs = FakeFs::new(cx.executor());
5010 fs.insert_tree(
5011 "/project",
5012 json!({
5013 ".git": {},
5014 "src": {
5015 "main.rs": "fn main() {}"
5016 }
5017 }),
5018 )
5019 .await;
5020 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5021
5022 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5023
5024 let multi_workspace =
5025 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5026
5027 let workspace = multi_workspace
5028 .read_with(cx, |multi_workspace, _cx| {
5029 multi_workspace.workspace().clone()
5030 })
5031 .unwrap();
5032
5033 workspace.update(cx, |workspace, _cx| {
5034 workspace.set_random_database_id();
5035 });
5036
5037 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5038
5039 // Wait for the project to discover the git repository.
5040 cx.run_until_parked();
5041
5042 let panel = workspace.update_in(cx, |workspace, window, cx| {
5043 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5044 workspace.add_panel(panel.clone(), window, cx);
5045 panel
5046 });
5047
5048 cx.run_until_parked();
5049
5050 // Default thread target should be LocalProject.
5051 panel.read_with(cx, |panel, _cx| {
5052 assert_eq!(
5053 *panel.start_thread_in(),
5054 StartThreadIn::LocalProject,
5055 "default thread target should be LocalProject"
5056 );
5057 });
5058
5059 // Start a new thread with the default LocalProject target.
5060 // Use StubAgentServer so the thread connects immediately in tests.
5061 panel.update_in(cx, |panel, window, cx| {
5062 panel.open_external_thread_with_server(
5063 Rc::new(StubAgentServer::default_response()),
5064 window,
5065 cx,
5066 );
5067 });
5068
5069 cx.run_until_parked();
5070
5071 // MultiWorkspace should still have exactly one workspace (no worktree created).
5072 multi_workspace
5073 .read_with(cx, |multi_workspace, _cx| {
5074 assert_eq!(
5075 multi_workspace.workspaces().len(),
5076 1,
5077 "LocalProject should not create a new workspace"
5078 );
5079 })
5080 .unwrap();
5081
5082 // The thread should be active in the panel.
5083 panel.read_with(cx, |panel, cx| {
5084 assert!(
5085 panel.active_agent_thread(cx).is_some(),
5086 "a thread should be running in the current workspace"
5087 );
5088 });
5089
5090 // The thread target should still be LocalProject (unchanged).
5091 panel.read_with(cx, |panel, _cx| {
5092 assert_eq!(
5093 *panel.start_thread_in(),
5094 StartThreadIn::LocalProject,
5095 "thread target should remain LocalProject"
5096 );
5097 });
5098
5099 // No worktree creation status should be set.
5100 panel.read_with(cx, |panel, _cx| {
5101 assert!(
5102 panel.worktree_creation_status.is_none(),
5103 "no worktree creation should have occurred"
5104 );
5105 });
5106 }
5107
5108 #[gpui::test]
5109 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5110 init_test(cx);
5111 cx.update(|cx| {
5112 cx.update_flags(true, vec!["agent-v2".to_string()]);
5113 agent::ThreadStore::init_global(cx);
5114 language_model::LanguageModelRegistry::test(cx);
5115 });
5116
5117 let fs = FakeFs::new(cx.executor());
5118 fs.insert_tree(
5119 "/project",
5120 json!({
5121 ".git": {},
5122 "src": {
5123 "main.rs": "fn main() {}"
5124 }
5125 }),
5126 )
5127 .await;
5128 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5129
5130 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5131
5132 let multi_workspace =
5133 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5134
5135 let workspace = multi_workspace
5136 .read_with(cx, |multi_workspace, _cx| {
5137 multi_workspace.workspace().clone()
5138 })
5139 .unwrap();
5140
5141 workspace.update(cx, |workspace, _cx| {
5142 workspace.set_random_database_id();
5143 });
5144
5145 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5146
5147 // Wait for the project to discover the git repository.
5148 cx.run_until_parked();
5149
5150 let panel = workspace.update_in(cx, |workspace, window, cx| {
5151 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5152 workspace.add_panel(panel.clone(), window, cx);
5153 panel
5154 });
5155
5156 cx.run_until_parked();
5157
5158 // Default should be LocalProject.
5159 panel.read_with(cx, |panel, _cx| {
5160 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5161 });
5162
5163 // Change thread target to NewWorktree.
5164 panel.update_in(cx, |panel, window, cx| {
5165 panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
5166 });
5167
5168 panel.read_with(cx, |panel, _cx| {
5169 assert_eq!(
5170 *panel.start_thread_in(),
5171 StartThreadIn::NewWorktree,
5172 "thread target should be NewWorktree after set_thread_target"
5173 );
5174 });
5175
5176 // Let serialization complete.
5177 cx.run_until_parked();
5178
5179 // Load a fresh panel from the serialized data.
5180 let async_cx = cx.update(|window, cx| window.to_async(cx));
5181 let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
5182 .await
5183 .expect("panel load should succeed");
5184 cx.run_until_parked();
5185
5186 loaded_panel.read_with(cx, |panel, _cx| {
5187 assert_eq!(
5188 *panel.start_thread_in(),
5189 StartThreadIn::NewWorktree,
5190 "thread target should survive serialization round-trip"
5191 );
5192 });
5193 }
5194
5195 #[gpui::test]
5196 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
5197 init_test(cx);
5198
5199 let fs = FakeFs::new(cx.executor());
5200 cx.update(|cx| {
5201 cx.update_flags(true, vec!["agent-v2".to_string()]);
5202 agent::ThreadStore::init_global(cx);
5203 language_model::LanguageModelRegistry::test(cx);
5204 <dyn fs::Fs>::set_global(fs.clone(), cx);
5205 });
5206
5207 fs.insert_tree(
5208 "/project",
5209 json!({
5210 ".git": {},
5211 "src": {
5212 "main.rs": "fn main() {}"
5213 }
5214 }),
5215 )
5216 .await;
5217
5218 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5219
5220 let multi_workspace =
5221 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5222
5223 let workspace = multi_workspace
5224 .read_with(cx, |multi_workspace, _cx| {
5225 multi_workspace.workspace().clone()
5226 })
5227 .unwrap();
5228
5229 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5230
5231 let panel = workspace.update_in(cx, |workspace, window, cx| {
5232 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5233 workspace.add_panel(panel.clone(), window, cx);
5234 panel
5235 });
5236
5237 cx.run_until_parked();
5238
5239 // Simulate worktree creation in progress and reset to Uninitialized
5240 panel.update_in(cx, |panel, window, cx| {
5241 panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
5242 panel.active_view = ActiveView::Uninitialized;
5243 Panel::set_active(panel, true, window, cx);
5244 assert!(
5245 matches!(panel.active_view, ActiveView::Uninitialized),
5246 "set_active should not create a thread while worktree is being created"
5247 );
5248 });
5249
5250 // Clear the creation status and use open_external_thread_with_server
5251 // (which bypasses new_agent_thread) to verify the panel can transition
5252 // out of Uninitialized. We can't call set_active directly because
5253 // new_agent_thread requires full agent server infrastructure.
5254 panel.update_in(cx, |panel, window, cx| {
5255 panel.worktree_creation_status = None;
5256 panel.active_view = ActiveView::Uninitialized;
5257 panel.open_external_thread_with_server(
5258 Rc::new(StubAgentServer::default_response()),
5259 window,
5260 cx,
5261 );
5262 });
5263
5264 cx.run_until_parked();
5265
5266 panel.read_with(cx, |panel, _cx| {
5267 assert!(
5268 !matches!(panel.active_view, ActiveView::Uninitialized),
5269 "panel should transition out of Uninitialized once worktree creation is cleared"
5270 );
5271 });
5272 }
5273
5274 #[test]
5275 fn test_deserialize_agent_variants() {
5276 // PascalCase (legacy AgentType format, persisted in panel state)
5277 assert_eq!(
5278 serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
5279 Agent::NativeAgent,
5280 );
5281 assert_eq!(
5282 serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
5283 Agent::Custom {
5284 id: "my-agent".into(),
5285 },
5286 );
5287
5288 // Legacy TextThread variant deserializes to NativeAgent
5289 assert_eq!(
5290 serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
5291 Agent::NativeAgent,
5292 );
5293
5294 // snake_case (canonical format)
5295 assert_eq!(
5296 serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
5297 Agent::NativeAgent,
5298 );
5299 assert_eq!(
5300 serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
5301 Agent::Custom {
5302 id: "my-agent".into(),
5303 },
5304 );
5305
5306 // Serialization uses snake_case
5307 assert_eq!(
5308 serde_json::to_string(&Agent::NativeAgent).unwrap(),
5309 r#""native_agent""#,
5310 );
5311 assert_eq!(
5312 serde_json::to_string(&Agent::Custom {
5313 id: "my-agent".into()
5314 })
5315 .unwrap(),
5316 r#"{"custom":{"name":"my-agent"}}"#,
5317 );
5318 }
5319
5320 #[gpui::test]
5321 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
5322 init_test(cx);
5323
5324 let app_state = cx.update(|cx| {
5325 cx.update_flags(true, vec!["agent-v2".to_string()]);
5326 agent::ThreadStore::init_global(cx);
5327 language_model::LanguageModelRegistry::test(cx);
5328
5329 let app_state = workspace::AppState::test(cx);
5330 workspace::init(app_state.clone(), cx);
5331 app_state
5332 });
5333
5334 let fs = app_state.fs.as_fake();
5335 fs.insert_tree(
5336 "/project",
5337 json!({
5338 ".git": {},
5339 "src": {
5340 "main.rs": "fn main() {}"
5341 }
5342 }),
5343 )
5344 .await;
5345 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5346
5347 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
5348
5349 let multi_workspace =
5350 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5351
5352 let workspace = multi_workspace
5353 .read_with(cx, |multi_workspace, _cx| {
5354 multi_workspace.workspace().clone()
5355 })
5356 .unwrap();
5357
5358 workspace.update(cx, |workspace, _cx| {
5359 workspace.set_random_database_id();
5360 });
5361
5362 // Register a callback so new workspaces also get an AgentPanel.
5363 cx.update(|cx| {
5364 cx.observe_new(
5365 |workspace: &mut Workspace,
5366 window: Option<&mut Window>,
5367 cx: &mut Context<Workspace>| {
5368 if let Some(window) = window {
5369 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5370 workspace.add_panel(panel, window, cx);
5371 }
5372 },
5373 )
5374 .detach();
5375 });
5376
5377 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5378
5379 // Wait for the project to discover the git repository.
5380 cx.run_until_parked();
5381
5382 let panel = workspace.update_in(cx, |workspace, window, cx| {
5383 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5384 workspace.add_panel(panel.clone(), window, cx);
5385 panel
5386 });
5387
5388 cx.run_until_parked();
5389
5390 // Open a thread (needed so there's an active thread view).
5391 panel.update_in(cx, |panel, window, cx| {
5392 panel.open_external_thread_with_server(
5393 Rc::new(StubAgentServer::default_response()),
5394 window,
5395 cx,
5396 );
5397 });
5398
5399 cx.run_until_parked();
5400
5401 // Set the selected agent to Codex (a custom agent) and start_thread_in
5402 // to NewWorktree. We do this AFTER opening the thread because
5403 // open_external_thread_with_server overrides selected_agent.
5404 panel.update_in(cx, |panel, window, cx| {
5405 panel.selected_agent = Agent::Custom {
5406 id: CODEX_ID.into(),
5407 };
5408 panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
5409 });
5410
5411 // Verify the panel has the Codex agent selected.
5412 panel.read_with(cx, |panel, _cx| {
5413 assert_eq!(
5414 panel.selected_agent,
5415 Agent::Custom {
5416 id: CODEX_ID.into()
5417 },
5418 );
5419 });
5420
5421 // Directly call handle_worktree_creation_requested, which is what
5422 // handle_first_send_requested does when start_thread_in == NewWorktree.
5423 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
5424 "Hello from test",
5425 ))];
5426 panel.update_in(cx, |panel, window, cx| {
5427 panel.handle_worktree_creation_requested(content, window, cx);
5428 });
5429
5430 // Let the async worktree creation + workspace setup complete.
5431 cx.run_until_parked();
5432
5433 // Find the new workspace's AgentPanel and verify it used the Codex agent.
5434 let found_codex = multi_workspace
5435 .read_with(cx, |multi_workspace, cx| {
5436 // There should be more than one workspace now (the original + the new worktree).
5437 assert!(
5438 multi_workspace.workspaces().len() > 1,
5439 "expected a new workspace to have been created, found {}",
5440 multi_workspace.workspaces().len(),
5441 );
5442
5443 // Check the newest workspace's panel for the correct agent.
5444 let new_workspace = multi_workspace
5445 .workspaces()
5446 .iter()
5447 .find(|ws| ws.entity_id() != workspace.entity_id())
5448 .expect("should find the new workspace");
5449 let new_panel = new_workspace
5450 .read(cx)
5451 .panel::<AgentPanel>(cx)
5452 .expect("new workspace should have an AgentPanel");
5453
5454 new_panel.read(cx).selected_agent.clone()
5455 })
5456 .unwrap();
5457
5458 assert_eq!(
5459 found_codex,
5460 Agent::Custom {
5461 id: CODEX_ID.into()
5462 },
5463 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
5464 );
5465 }
5466
5467 #[gpui::test]
5468 async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
5469 use crate::thread_metadata_store::ThreadMetadataStore;
5470
5471 init_test(cx);
5472 cx.update(|cx| {
5473 cx.update_flags(true, vec!["agent-v2".to_string()]);
5474 agent::ThreadStore::init_global(cx);
5475 language_model::LanguageModelRegistry::test(cx);
5476 });
5477
5478 // Set up a project with one worktree.
5479 let fs = FakeFs::new(cx.executor());
5480 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
5481 .await;
5482 let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
5483
5484 let multi_workspace =
5485 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5486 let workspace = multi_workspace
5487 .read_with(cx, |mw, _cx| mw.workspace().clone())
5488 .unwrap();
5489 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5490
5491 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5492 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5493 });
5494
5495 // Open thread A and send a message. With empty next_prompt_updates it
5496 // stays generating, so opening B will move A to background_threads.
5497 let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
5498 open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
5499 send_message(&panel, &mut cx);
5500 let session_id_a = active_session_id(&panel, &cx);
5501
5502 // Open thread C — thread A (generating) moves to background.
5503 // Thread C completes immediately (idle), then opening B moves C to background too.
5504 let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
5505 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5506 acp::ContentChunk::new("done".into()),
5507 )]);
5508 open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
5509 send_message(&panel, &mut cx);
5510 let session_id_c = active_session_id(&panel, &cx);
5511
5512 // Open thread B — thread C (idle, non-loadable) is retained in background.
5513 let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
5514 open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
5515 send_message(&panel, &mut cx);
5516 let session_id_b = active_session_id(&panel, &cx);
5517
5518 let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
5519
5520 panel.read_with(&cx, |panel, _cx| {
5521 assert!(
5522 panel.background_threads.contains_key(&session_id_a),
5523 "Thread A should be in background_threads"
5524 );
5525 assert!(
5526 panel.background_threads.contains_key(&session_id_c),
5527 "Thread C should be in background_threads"
5528 );
5529 });
5530
5531 // Verify initial work_dirs for thread B contain only /project_a.
5532 let initial_b_paths = panel.read_with(&cx, |panel, cx| {
5533 let thread = panel.active_agent_thread(cx).unwrap();
5534 thread.read(cx).work_dirs().cloned().unwrap()
5535 });
5536 assert_eq!(
5537 initial_b_paths.ordered_paths().collect::<Vec<_>>(),
5538 vec![&PathBuf::from("/project_a")],
5539 "Thread B should initially have only /project_a"
5540 );
5541
5542 // Now add a second worktree to the project.
5543 fs.insert_tree("/project_b", json!({ "other.txt": "" }))
5544 .await;
5545 let (new_tree, _) = project
5546 .update(&mut cx, |project, cx| {
5547 project.find_or_create_worktree("/project_b", true, cx)
5548 })
5549 .await
5550 .unwrap();
5551 cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
5552 .await;
5553 cx.run_until_parked();
5554
5555 // Verify thread B's (active) work_dirs now include both worktrees.
5556 let updated_b_paths = panel.read_with(&cx, |panel, cx| {
5557 let thread = panel.active_agent_thread(cx).unwrap();
5558 thread.read(cx).work_dirs().cloned().unwrap()
5559 });
5560 let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
5561 b_paths_sorted.sort();
5562 assert_eq!(
5563 b_paths_sorted,
5564 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5565 "Thread B work_dirs should include both worktrees after adding /project_b"
5566 );
5567
5568 // Verify thread A's (background) work_dirs are also updated.
5569 let updated_a_paths = panel.read_with(&cx, |panel, cx| {
5570 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
5571 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
5572 root_thread
5573 .read(cx)
5574 .thread
5575 .read(cx)
5576 .work_dirs()
5577 .cloned()
5578 .unwrap()
5579 });
5580 let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
5581 a_paths_sorted.sort();
5582 assert_eq!(
5583 a_paths_sorted,
5584 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5585 "Thread A work_dirs should include both worktrees after adding /project_b"
5586 );
5587
5588 // Verify thread idle C was also updated.
5589 let updated_c_paths = panel.read_with(&cx, |panel, cx| {
5590 let bg_view = panel.background_threads.get(&session_id_c).unwrap();
5591 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
5592 root_thread
5593 .read(cx)
5594 .thread
5595 .read(cx)
5596 .work_dirs()
5597 .cloned()
5598 .unwrap()
5599 });
5600 let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
5601 c_paths_sorted.sort();
5602 assert_eq!(
5603 c_paths_sorted,
5604 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5605 "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
5606 );
5607
5608 // Verify the metadata store reflects the new paths for running threads only.
5609 cx.run_until_parked();
5610 for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
5611 let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
5612 let metadata = store
5613 .entry(session_id)
5614 .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
5615 metadata.folder_paths.clone()
5616 });
5617 let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
5618 sorted.sort();
5619 assert_eq!(
5620 sorted,
5621 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5622 "{label} thread metadata folder_paths should include both worktrees"
5623 );
5624 }
5625
5626 // Now remove a worktree and verify work_dirs shrink.
5627 let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
5628 project.update(&mut cx, |project, cx| {
5629 project.remove_worktree(worktree_b_id, cx);
5630 });
5631 cx.run_until_parked();
5632
5633 let after_remove_b = panel.read_with(&cx, |panel, cx| {
5634 let thread = panel.active_agent_thread(cx).unwrap();
5635 thread.read(cx).work_dirs().cloned().unwrap()
5636 });
5637 assert_eq!(
5638 after_remove_b.ordered_paths().collect::<Vec<_>>(),
5639 vec![&PathBuf::from("/project_a")],
5640 "Thread B work_dirs should revert to only /project_a after removing /project_b"
5641 );
5642
5643 let after_remove_a = panel.read_with(&cx, |panel, cx| {
5644 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
5645 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
5646 root_thread
5647 .read(cx)
5648 .thread
5649 .read(cx)
5650 .work_dirs()
5651 .cloned()
5652 .unwrap()
5653 });
5654 assert_eq!(
5655 after_remove_a.ordered_paths().collect::<Vec<_>>(),
5656 vec![&PathBuf::from("/project_a")],
5657 "Thread A work_dirs should revert to only /project_a after removing /project_b"
5658 );
5659 }
5660
5661 #[gpui::test]
5662 async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
5663 init_test(cx);
5664 cx.update(|cx| {
5665 cx.update_flags(true, vec!["agent-v2".to_string()]);
5666 agent::ThreadStore::init_global(cx);
5667 language_model::LanguageModelRegistry::test(cx);
5668 // Use an isolated DB so parallel tests can't overwrite our global key.
5669 cx.set_global(db::AppDatabase::test_new());
5670 });
5671
5672 let custom_agent = Agent::Custom {
5673 id: "my-preferred-agent".into(),
5674 };
5675
5676 // Write a known agent to the global KVP to simulate a user who has
5677 // previously used this agent in another workspace.
5678 let kvp = cx.update(|cx| KeyValueStore::global(cx));
5679 write_global_last_used_agent(kvp, custom_agent.clone()).await;
5680
5681 let fs = FakeFs::new(cx.executor());
5682 let project = Project::test(fs.clone(), [], cx).await;
5683
5684 let multi_workspace =
5685 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5686
5687 let workspace = multi_workspace
5688 .read_with(cx, |multi_workspace, _cx| {
5689 multi_workspace.workspace().clone()
5690 })
5691 .unwrap();
5692
5693 workspace.update(cx, |workspace, _cx| {
5694 workspace.set_random_database_id();
5695 });
5696
5697 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5698
5699 // Load the panel via `load()`, which reads the global fallback
5700 // asynchronously when no per-workspace state exists.
5701 let async_cx = cx.update(|window, cx| window.to_async(cx));
5702 let panel = AgentPanel::load(workspace.downgrade(), async_cx)
5703 .await
5704 .expect("panel load should succeed");
5705 cx.run_until_parked();
5706
5707 panel.read_with(cx, |panel, _cx| {
5708 assert_eq!(
5709 panel.selected_agent, custom_agent,
5710 "new workspace should inherit the global last-used agent"
5711 );
5712 });
5713 }
5714
5715 #[gpui::test]
5716 async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
5717 init_test(cx);
5718 cx.update(|cx| {
5719 cx.update_flags(true, vec!["agent-v2".to_string()]);
5720 agent::ThreadStore::init_global(cx);
5721 language_model::LanguageModelRegistry::test(cx);
5722 });
5723
5724 let fs = FakeFs::new(cx.executor());
5725 let project_a = Project::test(fs.clone(), [], cx).await;
5726 let project_b = Project::test(fs, [], cx).await;
5727
5728 let multi_workspace =
5729 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5730
5731 let workspace_a = multi_workspace
5732 .read_with(cx, |multi_workspace, _cx| {
5733 multi_workspace.workspace().clone()
5734 })
5735 .unwrap();
5736
5737 let workspace_b = multi_workspace
5738 .update(cx, |multi_workspace, window, cx| {
5739 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
5740 })
5741 .unwrap();
5742
5743 workspace_a.update(cx, |workspace, _cx| {
5744 workspace.set_random_database_id();
5745 });
5746 workspace_b.update(cx, |workspace, _cx| {
5747 workspace.set_random_database_id();
5748 });
5749
5750 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5751
5752 let agent_a = Agent::Custom {
5753 id: "agent-alpha".into(),
5754 };
5755 let agent_b = Agent::Custom {
5756 id: "agent-beta".into(),
5757 };
5758
5759 // Set up workspace A with agent_a
5760 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
5761 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5762 });
5763 panel_a.update(cx, |panel, _cx| {
5764 panel.selected_agent = agent_a.clone();
5765 });
5766
5767 // Set up workspace B with agent_b
5768 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
5769 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5770 });
5771 panel_b.update(cx, |panel, _cx| {
5772 panel.selected_agent = agent_b.clone();
5773 });
5774
5775 // Serialize both panels
5776 panel_a.update(cx, |panel, cx| panel.serialize(cx));
5777 panel_b.update(cx, |panel, cx| panel.serialize(cx));
5778 cx.run_until_parked();
5779
5780 // Load fresh panels from serialized state and verify independence
5781 let async_cx = cx.update(|window, cx| window.to_async(cx));
5782 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
5783 .await
5784 .expect("panel A load should succeed");
5785 cx.run_until_parked();
5786
5787 let async_cx = cx.update(|window, cx| window.to_async(cx));
5788 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
5789 .await
5790 .expect("panel B load should succeed");
5791 cx.run_until_parked();
5792
5793 loaded_a.read_with(cx, |panel, _cx| {
5794 assert_eq!(
5795 panel.selected_agent, agent_a,
5796 "workspace A should restore agent-alpha, not agent-beta"
5797 );
5798 });
5799
5800 loaded_b.read_with(cx, |panel, _cx| {
5801 assert_eq!(
5802 panel.selected_agent, agent_b,
5803 "workspace B should restore agent-beta, not agent-alpha"
5804 );
5805 });
5806 }
5807
5808 #[gpui::test]
5809 async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
5810 init_test(cx);
5811 cx.update(|cx| {
5812 cx.update_flags(true, vec!["agent-v2".to_string()]);
5813 agent::ThreadStore::init_global(cx);
5814 language_model::LanguageModelRegistry::test(cx);
5815 });
5816
5817 let fs = FakeFs::new(cx.executor());
5818 let project = Project::test(fs.clone(), [], cx).await;
5819
5820 let multi_workspace =
5821 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5822
5823 let workspace = multi_workspace
5824 .read_with(cx, |multi_workspace, _cx| {
5825 multi_workspace.workspace().clone()
5826 })
5827 .unwrap();
5828
5829 workspace.update(cx, |workspace, _cx| {
5830 workspace.set_random_database_id();
5831 });
5832
5833 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5834
5835 let custom_agent = Agent::Custom {
5836 id: "my-custom-agent".into(),
5837 };
5838
5839 let panel = workspace.update_in(cx, |workspace, window, cx| {
5840 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5841 workspace.add_panel(panel.clone(), window, cx);
5842 panel
5843 });
5844
5845 // Set selected_agent to a custom agent
5846 panel.update(cx, |panel, _cx| {
5847 panel.selected_agent = custom_agent.clone();
5848 });
5849
5850 // Call new_thread, which internally calls external_thread(None, ...)
5851 // This resolves the agent from self.selected_agent
5852 panel.update_in(cx, |panel, window, cx| {
5853 panel.new_thread(&NewThread, window, cx);
5854 });
5855
5856 panel.read_with(cx, |panel, _cx| {
5857 assert_eq!(
5858 panel.selected_agent, custom_agent,
5859 "selected_agent should remain the custom agent after new_thread"
5860 );
5861 assert!(
5862 panel.active_conversation_view().is_some(),
5863 "a thread should have been created"
5864 );
5865 });
5866 }
5867}