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