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