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