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