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