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, AgentSessionInfo, MentionUri};
13use agent::{ContextServerRegistry, SharedThread, ThreadStore};
14use agent_client_protocol as acp;
15use agent_servers::AgentServer;
16use db::kvp::{Dismissable, KEY_VALUE_STORE};
17use itertools::Itertools;
18use project::{
19 ExternalAgentServerName,
20 agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME},
21};
22use serde::{Deserialize, Serialize};
23use settings::{LanguageModelProviderSetting, LanguageModelSelection};
24
25use feature_flags::{AgentGitWorktreesFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _};
26use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff};
27
28use crate::ManageProfiles;
29use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
30use crate::{
31 AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
32 InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown,
33 OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
34 ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
35 agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
36 connection_view::{AcpThreadViewEvent, ThreadView},
37 slash_command::SlashCommandCompletionProvider,
38 text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate},
39 ui::EndTrialUpsell,
40};
41use crate::{
42 AgentInitialContent, ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary,
43};
44use crate::{
45 ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
46 text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
47};
48use agent_settings::AgentSettings;
49use ai_onboarding::AgentPanelOnboarding;
50use anyhow::{Result, anyhow};
51use assistant_slash_command::SlashCommandWorkingSet;
52use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
53use client::UserStore;
54use cloud_api_types::Plan;
55use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
56use extension::ExtensionEvents;
57use extension_host::ExtensionStore;
58use fs::Fs;
59use git::repository::validate_worktree_directory;
60use gpui::{
61 Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
62 DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
63 Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
64};
65use language::LanguageRegistry;
66use language_model::{ConfigurationError, LanguageModelRegistry};
67use project::project_settings::ProjectSettings;
68use project::{Project, ProjectPath, Worktree};
69use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
70use rand::Rng as _;
71use rules_library::{RulesLibrary, open_rules_library};
72use search::{BufferSearchBar, buffer_search};
73use settings::{Settings, update_settings_file};
74use theme::ThemeSettings;
75use ui::{
76 Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
77 PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
78};
79use util::ResultExt as _;
80use workspace::{
81 CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
82 WorkspaceId,
83 dock::{DockPosition, Panel, PanelEvent},
84};
85use zed_actions::{
86 DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
87 agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
88 assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
89};
90
91const AGENT_PANEL_KEY: &str = "agent_panel";
92const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
93const DEFAULT_THREAD_TITLE: &str = "New Thread";
94
95fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
96 let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
97 let key = i64::from(workspace_id).to_string();
98 scope
99 .read(&key)
100 .log_err()
101 .flatten()
102 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
103}
104
105async fn save_serialized_panel(
106 workspace_id: workspace::WorkspaceId,
107 panel: SerializedAgentPanel,
108) -> Result<()> {
109 let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
110 let key = i64::from(workspace_id).to_string();
111 scope.write(key, serde_json::to_string(&panel)?).await?;
112 Ok(())
113}
114
115/// Migration: reads the original single-panel format stored under the
116/// `"agent_panel"` KVP key before per-workspace keying was introduced.
117fn read_legacy_serialized_panel() -> Option<SerializedAgentPanel> {
118 KEY_VALUE_STORE
119 .read_kvp(AGENT_PANEL_KEY)
120 .log_err()
121 .flatten()
122 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
123}
124
125#[derive(Serialize, Deserialize, Debug, Clone)]
126struct SerializedAgentPanel {
127 width: Option<Pixels>,
128 selected_agent: Option<AgentType>,
129 #[serde(default)]
130 last_active_thread: Option<SerializedActiveThread>,
131 #[serde(default)]
132 start_thread_in: Option<StartThreadIn>,
133}
134
135#[derive(Serialize, Deserialize, Debug, Clone)]
136struct SerializedActiveThread {
137 session_id: String,
138 agent_type: AgentType,
139 title: Option<String>,
140 cwd: Option<std::path::PathBuf>,
141}
142
143pub fn init(cx: &mut App) {
144 cx.observe_new(
145 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
146 workspace
147 .register_action(|workspace, action: &NewThread, window, cx| {
148 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
149 panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
150 workspace.focus_panel::<AgentPanel>(window, cx);
151 }
152 })
153 .register_action(
154 |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
155 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
156 panel.update(cx, |panel, cx| {
157 panel.new_native_agent_thread_from_summary(action, window, cx)
158 });
159 workspace.focus_panel::<AgentPanel>(window, cx);
160 }
161 },
162 )
163 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
164 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
165 workspace.focus_panel::<AgentPanel>(window, cx);
166 panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
167 }
168 })
169 .register_action(|workspace, _: &OpenHistory, window, cx| {
170 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
171 workspace.focus_panel::<AgentPanel>(window, cx);
172 panel.update(cx, |panel, cx| panel.open_history(window, cx));
173 }
174 })
175 .register_action(|workspace, _: &OpenSettings, window, cx| {
176 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
177 workspace.focus_panel::<AgentPanel>(window, cx);
178 panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
179 }
180 })
181 .register_action(|workspace, _: &NewTextThread, window, cx| {
182 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
183 workspace.focus_panel::<AgentPanel>(window, cx);
184 panel.update(cx, |panel, cx| {
185 panel.new_text_thread(window, cx);
186 });
187 }
188 })
189 .register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
190 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
191 workspace.focus_panel::<AgentPanel>(window, cx);
192 panel.update(cx, |panel, cx| {
193 panel.external_thread(action.agent.clone(), None, None, window, cx)
194 });
195 }
196 })
197 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
198 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
199 workspace.focus_panel::<AgentPanel>(window, cx);
200 panel.update(cx, |panel, cx| {
201 panel.deploy_rules_library(action, window, cx)
202 });
203 }
204 })
205 .register_action(|workspace, _: &Follow, window, cx| {
206 workspace.follow(CollaboratorId::Agent, window, cx);
207 })
208 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
209 let thread = workspace
210 .panel::<AgentPanel>(cx)
211 .and_then(|panel| panel.read(cx).active_thread_view().cloned())
212 .and_then(|thread_view| {
213 thread_view
214 .read(cx)
215 .active_thread()
216 .map(|r| r.read(cx).thread.clone())
217 });
218
219 if let Some(thread) = thread {
220 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
221 }
222 })
223 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
224 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
225 workspace.focus_panel::<AgentPanel>(window, cx);
226 panel.update(cx, |panel, cx| {
227 panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
228 });
229 }
230 })
231 .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
232 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
233 workspace.focus_panel::<AgentPanel>(window, cx);
234 panel.update(cx, |panel, cx| {
235 panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
236 });
237 }
238 })
239 .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
240 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
241 workspace.focus_panel::<AgentPanel>(window, cx);
242 panel.update(cx, |panel, cx| {
243 panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
244 });
245 }
246 })
247 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
248 AcpOnboardingModal::toggle(workspace, window, cx)
249 })
250 .register_action(
251 |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| {
252 ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
253 },
254 )
255 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
256 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
257 window.refresh();
258 })
259 .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
260 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
261 panel.update(cx, |panel, _| {
262 panel
263 .on_boarding_upsell_dismissed
264 .store(false, Ordering::Release);
265 });
266 }
267 OnboardingUpsell::set_dismissed(false, cx);
268 })
269 .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
270 TrialEndUpsell::set_dismissed(false, cx);
271 })
272 .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
273 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
274 panel.update(cx, |panel, cx| {
275 panel.reset_agent_zoom(window, cx);
276 });
277 }
278 })
279 .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
280 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
281 panel.update(cx, |panel, cx| {
282 panel.copy_thread_to_clipboard(window, cx);
283 });
284 }
285 })
286 .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
287 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
288 workspace.focus_panel::<AgentPanel>(window, cx);
289 panel.update(cx, |panel, cx| {
290 panel.load_thread_from_clipboard(window, cx);
291 });
292 }
293 })
294 .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
295 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
296 return;
297 };
298
299 let mention_uri = MentionUri::GitDiff {
300 base_ref: action.base_ref.to_string(),
301 };
302 let diff_uri = mention_uri.to_uri().to_string();
303
304 let content_blocks = vec![
305 acp::ContentBlock::Text(acp::TextContent::new(
306 "Please review this branch diff carefully. Point out any issues, \
307 potential bugs, or improvement opportunities you find.\n\n"
308 .to_string(),
309 )),
310 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
311 acp::EmbeddedResourceResource::TextResourceContents(
312 acp::TextResourceContents::new(
313 action.diff_text.to_string(),
314 diff_uri,
315 ),
316 ),
317 )),
318 ];
319
320 workspace.focus_panel::<AgentPanel>(window, cx);
321
322 panel.update(cx, |panel, cx| {
323 panel.external_thread(
324 None,
325 None,
326 Some(AgentInitialContent::ContentBlock {
327 blocks: content_blocks,
328 auto_submit: true,
329 }),
330 window,
331 cx,
332 );
333 });
334 })
335 .register_action(|workspace, action: &StartThreadIn, _window, cx| {
336 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
337 panel.update(cx, |panel, cx| {
338 panel.set_start_thread_in(action, cx);
339 });
340 }
341 });
342 },
343 )
344 .detach();
345}
346
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
348enum HistoryKind {
349 AgentThreads,
350 TextThreads,
351}
352
353enum ActiveView {
354 Uninitialized,
355 AgentThread {
356 server_view: Entity<ConnectionView>,
357 },
358 TextThread {
359 text_thread_editor: Entity<TextThreadEditor>,
360 title_editor: Entity<Editor>,
361 buffer_search_bar: Entity<BufferSearchBar>,
362 _subscriptions: Vec<gpui::Subscription>,
363 },
364 History {
365 kind: HistoryKind,
366 },
367 Configuration,
368}
369
370enum WhichFontSize {
371 AgentFont,
372 BufferFont,
373 None,
374}
375
376// TODO unify this with ExternalAgent
377#[derive(Debug, Default, Clone, PartialEq, Serialize)]
378pub enum AgentType {
379 #[default]
380 NativeAgent,
381 TextThread,
382 Custom {
383 name: SharedString,
384 },
385}
386
387// Custom impl handles legacy variant names from before the built-in agents were moved to
388// the registry: "ClaudeAgent" -> Custom { name: "claude-acp" }, "Codex" -> Custom { name:
389// "codex-acp" }, "Gemini" -> Custom { name: "gemini" }.
390// Can be removed at some point in the future and go back to #[derive(Deserialize)].
391impl<'de> Deserialize<'de> for AgentType {
392 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
393 where
394 D: serde::Deserializer<'de>,
395 {
396 let value = serde_json::Value::deserialize(deserializer)?;
397
398 if let Some(s) = value.as_str() {
399 return match s {
400 "NativeAgent" => Ok(Self::NativeAgent),
401 "TextThread" => Ok(Self::TextThread),
402 "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom {
403 name: CLAUDE_AGENT_NAME.into(),
404 }),
405 "Codex" => Ok(Self::Custom {
406 name: CODEX_NAME.into(),
407 }),
408 "Gemini" => Ok(Self::Custom {
409 name: GEMINI_NAME.into(),
410 }),
411 other => Err(serde::de::Error::unknown_variant(
412 other,
413 &[
414 "NativeAgent",
415 "TextThread",
416 "Custom",
417 "ClaudeAgent",
418 "ClaudeCode",
419 "Codex",
420 "Gemini",
421 ],
422 )),
423 };
424 }
425
426 if let Some(obj) = value.as_object() {
427 if let Some(inner) = obj.get("Custom") {
428 #[derive(Deserialize)]
429 struct CustomFields {
430 name: SharedString,
431 }
432 let fields: CustomFields =
433 serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
434 return Ok(Self::Custom { name: fields.name });
435 }
436 }
437
438 Err(serde::de::Error::custom(
439 "expected a string variant or {\"Custom\": {\"name\": ...}}",
440 ))
441 }
442}
443
444impl AgentType {
445 pub fn is_native(&self) -> bool {
446 matches!(self, Self::NativeAgent)
447 }
448
449 fn label(&self) -> SharedString {
450 match self {
451 Self::NativeAgent | Self::TextThread => "Zed Agent".into(),
452 Self::Custom { name, .. } => name.into(),
453 }
454 }
455
456 fn icon(&self) -> Option<IconName> {
457 match self {
458 Self::NativeAgent | Self::TextThread => None,
459 Self::Custom { .. } => Some(IconName::Sparkle),
460 }
461 }
462}
463
464impl From<ExternalAgent> for AgentType {
465 fn from(value: ExternalAgent) -> Self {
466 match value {
467 ExternalAgent::Custom { name } => Self::Custom { name },
468 ExternalAgent::NativeAgent => Self::NativeAgent,
469 }
470 }
471}
472
473impl StartThreadIn {
474 fn label(&self) -> SharedString {
475 match self {
476 Self::LocalProject => "Local Project".into(),
477 Self::NewWorktree => "New Worktree".into(),
478 }
479 }
480
481 fn icon(&self) -> IconName {
482 match self {
483 Self::LocalProject => IconName::Screen,
484 Self::NewWorktree => IconName::GitBranchPlus,
485 }
486 }
487}
488
489#[derive(Clone, Debug)]
490#[allow(dead_code)]
491pub enum WorktreeCreationStatus {
492 Creating,
493 Error(SharedString),
494}
495
496impl ActiveView {
497 pub fn which_font_size_used(&self) -> WhichFontSize {
498 match self {
499 ActiveView::Uninitialized
500 | ActiveView::AgentThread { .. }
501 | ActiveView::History { .. } => WhichFontSize::AgentFont,
502 ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
503 ActiveView::Configuration => WhichFontSize::None,
504 }
505 }
506
507 pub fn text_thread(
508 text_thread_editor: Entity<TextThreadEditor>,
509 language_registry: Arc<LanguageRegistry>,
510 window: &mut Window,
511 cx: &mut App,
512 ) -> Self {
513 let title = text_thread_editor.read(cx).title(cx).to_string();
514
515 let editor = cx.new(|cx| {
516 let mut editor = Editor::single_line(window, cx);
517 editor.set_text(title, window, cx);
518 editor
519 });
520
521 // This is a workaround for `editor.set_text` emitting a `BufferEdited` event, which would
522 // cause a custom summary to be set. The presence of this custom summary would cause
523 // summarization to not happen.
524 let mut suppress_first_edit = true;
525
526 let subscriptions = vec![
527 window.subscribe(&editor, cx, {
528 {
529 let text_thread_editor = text_thread_editor.clone();
530 move |editor, event, window, cx| match event {
531 EditorEvent::BufferEdited => {
532 if suppress_first_edit {
533 suppress_first_edit = false;
534 return;
535 }
536 let new_summary = editor.read(cx).text(cx);
537
538 text_thread_editor.update(cx, |text_thread_editor, cx| {
539 text_thread_editor
540 .text_thread()
541 .update(cx, |text_thread, cx| {
542 text_thread.set_custom_summary(new_summary, cx);
543 })
544 })
545 }
546 EditorEvent::Blurred => {
547 if editor.read(cx).text(cx).is_empty() {
548 let summary = text_thread_editor
549 .read(cx)
550 .text_thread()
551 .read(cx)
552 .summary()
553 .or_default();
554
555 editor.update(cx, |editor, cx| {
556 editor.set_text(summary, window, cx);
557 });
558 }
559 }
560 _ => {}
561 }
562 }
563 }),
564 window.subscribe(&text_thread_editor.read(cx).text_thread().clone(), cx, {
565 let editor = editor.clone();
566 move |text_thread, event, window, cx| match event {
567 TextThreadEvent::SummaryGenerated => {
568 let summary = text_thread.read(cx).summary().or_default();
569
570 editor.update(cx, |editor, cx| {
571 editor.set_text(summary, window, cx);
572 })
573 }
574 TextThreadEvent::PathChanged { .. } => {}
575 _ => {}
576 }
577 }),
578 ];
579
580 let buffer_search_bar =
581 cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx));
582 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
583 buffer_search_bar.set_active_pane_item(Some(&text_thread_editor), window, cx)
584 });
585
586 Self::TextThread {
587 text_thread_editor,
588 title_editor: editor,
589 buffer_search_bar,
590 _subscriptions: subscriptions,
591 }
592 }
593}
594
595pub struct AgentPanel {
596 workspace: WeakEntity<Workspace>,
597 /// Workspace id is used as a database key
598 workspace_id: Option<WorkspaceId>,
599 user_store: Entity<UserStore>,
600 project: Entity<Project>,
601 fs: Arc<dyn Fs>,
602 language_registry: Arc<LanguageRegistry>,
603 acp_history: Entity<ThreadHistory>,
604 text_thread_history: Entity<TextThreadHistory>,
605 thread_store: Entity<ThreadStore>,
606 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
607 prompt_store: Option<Entity<PromptStore>>,
608 context_server_registry: Entity<ContextServerRegistry>,
609 configuration: Option<Entity<AgentConfiguration>>,
610 configuration_subscription: Option<Subscription>,
611 focus_handle: FocusHandle,
612 active_view: ActiveView,
613 previous_view: Option<ActiveView>,
614 _active_view_observation: Option<Subscription>,
615 new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
616 start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
617 agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
618 agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
619 agent_navigation_menu: Option<Entity<ContextMenu>>,
620 _extension_subscription: Option<Subscription>,
621 width: Option<Pixels>,
622 height: Option<Pixels>,
623 zoomed: bool,
624 pending_serialization: Option<Task<Result<()>>>,
625 onboarding: Entity<AgentPanelOnboarding>,
626 selected_agent: AgentType,
627 start_thread_in: StartThreadIn,
628 worktree_creation_status: Option<WorktreeCreationStatus>,
629 _thread_view_subscription: Option<Subscription>,
630 _worktree_creation_task: Option<Task<()>>,
631 show_trust_workspace_message: bool,
632 last_configuration_error_telemetry: Option<String>,
633 on_boarding_upsell_dismissed: AtomicBool,
634}
635
636impl AgentPanel {
637 fn serialize(&mut self, cx: &mut App) {
638 let Some(workspace_id) = self.workspace_id else {
639 return;
640 };
641
642 let width = self.width;
643 let selected_agent = self.selected_agent.clone();
644 let start_thread_in = Some(self.start_thread_in);
645
646 let last_active_thread = self.active_agent_thread(cx).map(|thread| {
647 let thread = thread.read(cx);
648 let title = thread.title();
649 SerializedActiveThread {
650 session_id: thread.session_id().0.to_string(),
651 agent_type: self.selected_agent.clone(),
652 title: if title.as_ref() != DEFAULT_THREAD_TITLE {
653 Some(title.to_string())
654 } else {
655 None
656 },
657 cwd: None,
658 }
659 });
660
661 self.pending_serialization = Some(cx.background_spawn(async move {
662 save_serialized_panel(
663 workspace_id,
664 SerializedAgentPanel {
665 width,
666 selected_agent: Some(selected_agent),
667 last_active_thread,
668 start_thread_in,
669 },
670 )
671 .await?;
672 anyhow::Ok(())
673 }));
674 }
675
676 pub fn load(
677 workspace: WeakEntity<Workspace>,
678 prompt_builder: Arc<PromptBuilder>,
679 mut cx: AsyncWindowContext,
680 ) -> Task<Result<Entity<Self>>> {
681 let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
682 cx.spawn(async move |cx| {
683 let prompt_store = match prompt_store {
684 Ok(prompt_store) => prompt_store.await.ok(),
685 Err(_) => None,
686 };
687 let workspace_id = workspace
688 .read_with(cx, |workspace, _| workspace.database_id())
689 .ok()
690 .flatten();
691
692 let serialized_panel = cx
693 .background_spawn(async move {
694 workspace_id
695 .and_then(read_serialized_panel)
696 .or_else(read_legacy_serialized_panel)
697 })
698 .await;
699
700 let slash_commands = Arc::new(SlashCommandWorkingSet::default());
701 let text_thread_store = workspace
702 .update(cx, |workspace, cx| {
703 let project = workspace.project().clone();
704 assistant_text_thread::TextThreadStore::new(
705 project,
706 prompt_builder,
707 slash_commands,
708 cx,
709 )
710 })?
711 .await?;
712
713 let last_active_thread = if let Some(thread_info) = serialized_panel
714 .as_ref()
715 .and_then(|p| p.last_active_thread.clone())
716 {
717 if thread_info.agent_type.is_native() {
718 let session_id = acp::SessionId::new(thread_info.session_id.clone());
719 let load_result = cx.update(|_window, cx| {
720 let thread_store = ThreadStore::global(cx);
721 thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
722 });
723 let thread_exists = if let Ok(task) = load_result {
724 task.await.ok().flatten().is_some()
725 } else {
726 false
727 };
728 if thread_exists {
729 Some(thread_info)
730 } else {
731 log::warn!(
732 "last active thread {} not found in database, skipping restoration",
733 thread_info.session_id
734 );
735 None
736 }
737 } else {
738 Some(thread_info)
739 }
740 } else {
741 None
742 };
743
744 let panel = workspace.update_in(cx, |workspace, window, cx| {
745 let panel =
746 cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
747
748 if let Some(serialized_panel) = &serialized_panel {
749 panel.update(cx, |panel, cx| {
750 panel.width = serialized_panel.width.map(|w| w.round());
751 if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
752 panel.selected_agent = selected_agent;
753 }
754 if let Some(start_thread_in) = serialized_panel.start_thread_in {
755 let is_worktree_flag_enabled =
756 cx.has_flag::<AgentGitWorktreesFeatureFlag>();
757 let is_valid = match &start_thread_in {
758 StartThreadIn::LocalProject => true,
759 StartThreadIn::NewWorktree => {
760 let project = panel.project.read(cx);
761 is_worktree_flag_enabled && !project.is_via_collab()
762 }
763 };
764 if is_valid {
765 panel.start_thread_in = start_thread_in;
766 } else {
767 log::info!(
768 "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
769 start_thread_in,
770 );
771 }
772 }
773 cx.notify();
774 });
775 }
776
777 if let Some(thread_info) = last_active_thread {
778 let agent_type = thread_info.agent_type.clone();
779 let session_info = AgentSessionInfo {
780 session_id: acp::SessionId::new(thread_info.session_id),
781 cwd: thread_info.cwd,
782 title: thread_info.title.map(SharedString::from),
783 updated_at: None,
784 meta: None,
785 };
786 panel.update(cx, |panel, cx| {
787 panel.selected_agent = agent_type;
788 panel.load_agent_thread(session_info, window, cx);
789 });
790 }
791 panel
792 })?;
793
794 Ok(panel)
795 })
796 }
797
798 pub(crate) fn new(
799 workspace: &Workspace,
800 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
801 prompt_store: Option<Entity<PromptStore>>,
802 window: &mut Window,
803 cx: &mut Context<Self>,
804 ) -> Self {
805 let fs = workspace.app_state().fs.clone();
806 let user_store = workspace.app_state().user_store.clone();
807 let project = workspace.project();
808 let language_registry = project.read(cx).languages().clone();
809 let client = workspace.client().clone();
810 let workspace_id = workspace.database_id();
811 let workspace = workspace.weak_handle();
812
813 let context_server_registry =
814 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
815
816 let thread_store = ThreadStore::global(cx);
817 let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx));
818 let text_thread_history =
819 cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
820 cx.subscribe_in(
821 &acp_history,
822 window,
823 |this, _, event, window, cx| match event {
824 ThreadHistoryEvent::Open(thread) => {
825 this.load_agent_thread(thread.clone(), window, cx);
826 }
827 },
828 )
829 .detach();
830 cx.subscribe_in(
831 &text_thread_history,
832 window,
833 |this, _, event, window, cx| match event {
834 TextThreadHistoryEvent::Open(thread) => {
835 this.open_saved_text_thread(thread.path.clone(), window, cx)
836 .detach_and_log_err(cx);
837 }
838 },
839 )
840 .detach();
841
842 let active_view = ActiveView::Uninitialized;
843
844 let weak_panel = cx.entity().downgrade();
845
846 window.defer(cx, move |window, cx| {
847 let panel = weak_panel.clone();
848 let agent_navigation_menu =
849 ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
850 if let Some(panel) = panel.upgrade() {
851 if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) {
852 menu =
853 Self::populate_recently_updated_menu_section(menu, panel, kind, cx);
854 let view_all_label = match kind {
855 HistoryKind::AgentThreads => "View All",
856 HistoryKind::TextThreads => "View All Text Threads",
857 };
858 menu = menu.action(view_all_label, Box::new(OpenHistory));
859 }
860 }
861
862 menu = menu
863 .fixed_width(px(320.).into())
864 .keep_open_on_confirm(false)
865 .key_context("NavigationMenu");
866
867 menu
868 });
869 weak_panel
870 .update(cx, |panel, cx| {
871 cx.subscribe_in(
872 &agent_navigation_menu,
873 window,
874 |_, menu, _: &DismissEvent, window, cx| {
875 menu.update(cx, |menu, _| {
876 menu.clear_selected();
877 });
878 cx.focus_self(window);
879 },
880 )
881 .detach();
882 panel.agent_navigation_menu = Some(agent_navigation_menu);
883 })
884 .ok();
885 });
886
887 let weak_panel = cx.entity().downgrade();
888 let onboarding = cx.new(|cx| {
889 AgentPanelOnboarding::new(
890 user_store.clone(),
891 client,
892 move |_window, cx| {
893 weak_panel
894 .update(cx, |panel, _| {
895 panel
896 .on_boarding_upsell_dismissed
897 .store(true, Ordering::Release);
898 })
899 .ok();
900 OnboardingUpsell::set_dismissed(true, cx);
901 },
902 cx,
903 )
904 });
905
906 // Subscribe to extension events to sync agent servers when extensions change
907 let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
908 {
909 Some(
910 cx.subscribe(&extension_events, |this, _source, event, cx| match event {
911 extension::Event::ExtensionInstalled(_)
912 | extension::Event::ExtensionUninstalled(_)
913 | extension::Event::ExtensionsInstalledChanged => {
914 this.sync_agent_servers_from_extensions(cx);
915 }
916 _ => {}
917 }),
918 )
919 } else {
920 None
921 };
922
923 let mut panel = Self {
924 workspace_id,
925 active_view,
926 workspace,
927 user_store,
928 project: project.clone(),
929 fs: fs.clone(),
930 language_registry,
931 text_thread_store,
932 prompt_store,
933 configuration: None,
934 configuration_subscription: None,
935 focus_handle: cx.focus_handle(),
936 context_server_registry,
937 previous_view: None,
938 _active_view_observation: None,
939 new_thread_menu_handle: PopoverMenuHandle::default(),
940 start_thread_in_menu_handle: PopoverMenuHandle::default(),
941 agent_panel_menu_handle: PopoverMenuHandle::default(),
942 agent_navigation_menu_handle: PopoverMenuHandle::default(),
943 agent_navigation_menu: None,
944 _extension_subscription: extension_subscription,
945 width: None,
946 height: None,
947 zoomed: false,
948 pending_serialization: None,
949 onboarding,
950 acp_history,
951 text_thread_history,
952 thread_store,
953 selected_agent: AgentType::default(),
954 start_thread_in: StartThreadIn::default(),
955 worktree_creation_status: None,
956 _thread_view_subscription: None,
957 _worktree_creation_task: None,
958 show_trust_workspace_message: false,
959 last_configuration_error_telemetry: None,
960 on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
961 };
962
963 // Initial sync of agent servers from extensions
964 panel.sync_agent_servers_from_extensions(cx);
965 panel
966 }
967
968 pub fn toggle_focus(
969 workspace: &mut Workspace,
970 _: &ToggleFocus,
971 window: &mut Window,
972 cx: &mut Context<Workspace>,
973 ) {
974 if workspace
975 .panel::<Self>(cx)
976 .is_some_and(|panel| panel.read(cx).enabled(cx))
977 {
978 workspace.toggle_panel_focus::<Self>(window, cx);
979 }
980 }
981
982 pub fn toggle(
983 workspace: &mut Workspace,
984 _: &Toggle,
985 window: &mut Window,
986 cx: &mut Context<Workspace>,
987 ) {
988 if workspace
989 .panel::<Self>(cx)
990 .is_some_and(|panel| panel.read(cx).enabled(cx))
991 {
992 if !workspace.toggle_panel_focus::<Self>(window, cx) {
993 workspace.close_panel::<Self>(window, cx);
994 }
995 }
996 }
997
998 pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
999 &self.prompt_store
1000 }
1001
1002 pub fn thread_store(&self) -> &Entity<ThreadStore> {
1003 &self.thread_store
1004 }
1005
1006 pub fn history(&self) -> &Entity<ThreadHistory> {
1007 &self.acp_history
1008 }
1009
1010 pub fn open_thread(
1011 &mut self,
1012 thread: AgentSessionInfo,
1013 window: &mut Window,
1014 cx: &mut Context<Self>,
1015 ) {
1016 self.external_thread(
1017 Some(crate::ExternalAgent::NativeAgent),
1018 Some(thread),
1019 None,
1020 window,
1021 cx,
1022 );
1023 }
1024
1025 pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
1026 &self.context_server_registry
1027 }
1028
1029 pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
1030 let workspace_read = workspace.read(cx);
1031
1032 workspace_read
1033 .panel::<AgentPanel>(cx)
1034 .map(|panel| {
1035 let panel_id = Entity::entity_id(&panel);
1036
1037 workspace_read.all_docks().iter().any(|dock| {
1038 dock.read(cx)
1039 .visible_panel()
1040 .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1041 })
1042 })
1043 .unwrap_or(false)
1044 }
1045
1046 pub(crate) fn active_thread_view(&self) -> Option<&Entity<ConnectionView>> {
1047 match &self.active_view {
1048 ActiveView::AgentThread { server_view, .. } => Some(server_view),
1049 ActiveView::Uninitialized
1050 | ActiveView::TextThread { .. }
1051 | ActiveView::History { .. }
1052 | ActiveView::Configuration => None,
1053 }
1054 }
1055
1056 fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1057 self.new_agent_thread(AgentType::NativeAgent, window, cx);
1058 }
1059
1060 fn new_native_agent_thread_from_summary(
1061 &mut self,
1062 action: &NewNativeAgentThreadFromSummary,
1063 window: &mut Window,
1064 cx: &mut Context<Self>,
1065 ) {
1066 let Some(thread) = self
1067 .acp_history
1068 .read(cx)
1069 .session_for_id(&action.from_session_id)
1070 else {
1071 return;
1072 };
1073
1074 self.external_thread(
1075 Some(ExternalAgent::NativeAgent),
1076 None,
1077 Some(AgentInitialContent::ThreadSummary(thread)),
1078 window,
1079 cx,
1080 );
1081 }
1082
1083 fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1084 telemetry::event!("Agent Thread Started", agent = "zed-text");
1085
1086 let context = self
1087 .text_thread_store
1088 .update(cx, |context_store, cx| context_store.create(cx));
1089 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx)
1090 .log_err()
1091 .flatten();
1092
1093 let text_thread_editor = cx.new(|cx| {
1094 let mut editor = TextThreadEditor::for_text_thread(
1095 context,
1096 self.fs.clone(),
1097 self.workspace.clone(),
1098 self.project.clone(),
1099 lsp_adapter_delegate,
1100 window,
1101 cx,
1102 );
1103 editor.insert_default_prompt(window, cx);
1104 editor
1105 });
1106
1107 if self.selected_agent != AgentType::TextThread {
1108 self.selected_agent = AgentType::TextThread;
1109 self.serialize(cx);
1110 }
1111
1112 self.set_active_view(
1113 ActiveView::text_thread(
1114 text_thread_editor.clone(),
1115 self.language_registry.clone(),
1116 window,
1117 cx,
1118 ),
1119 true,
1120 window,
1121 cx,
1122 );
1123 text_thread_editor.focus_handle(cx).focus(window, cx);
1124 }
1125
1126 fn external_thread(
1127 &mut self,
1128 agent_choice: Option<crate::ExternalAgent>,
1129 resume_thread: Option<AgentSessionInfo>,
1130 initial_content: Option<AgentInitialContent>,
1131 window: &mut Window,
1132 cx: &mut Context<Self>,
1133 ) {
1134 let workspace = self.workspace.clone();
1135 let project = self.project.clone();
1136 let fs = self.fs.clone();
1137 let is_via_collab = self.project.read(cx).is_via_collab();
1138
1139 const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
1140
1141 #[derive(Serialize, Deserialize)]
1142 struct LastUsedExternalAgent {
1143 agent: crate::ExternalAgent,
1144 }
1145
1146 let thread_store = self.thread_store.clone();
1147
1148 cx.spawn_in(window, async move |this, cx| {
1149 let ext_agent = match agent_choice {
1150 Some(agent) => {
1151 cx.background_spawn({
1152 let agent = agent.clone();
1153 async move {
1154 if let Some(serialized) =
1155 serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
1156 {
1157 KEY_VALUE_STORE
1158 .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
1159 .await
1160 .log_err();
1161 }
1162 }
1163 })
1164 .detach();
1165
1166 agent
1167 }
1168 None => {
1169 if is_via_collab {
1170 ExternalAgent::NativeAgent
1171 } else {
1172 cx.background_spawn(async move {
1173 KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
1174 })
1175 .await
1176 .log_err()
1177 .flatten()
1178 .and_then(|value| {
1179 serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
1180 })
1181 .map(|agent| agent.agent)
1182 .unwrap_or(ExternalAgent::NativeAgent)
1183 }
1184 }
1185 };
1186
1187 let server = ext_agent.server(fs, thread_store);
1188 this.update_in(cx, |agent_panel, window, cx| {
1189 agent_panel.create_external_thread(
1190 server,
1191 resume_thread,
1192 initial_content,
1193 workspace,
1194 project,
1195 ext_agent,
1196 window,
1197 cx,
1198 );
1199 })?;
1200
1201 anyhow::Ok(())
1202 })
1203 .detach_and_log_err(cx);
1204 }
1205
1206 fn deploy_rules_library(
1207 &mut self,
1208 action: &OpenRulesLibrary,
1209 _window: &mut Window,
1210 cx: &mut Context<Self>,
1211 ) {
1212 open_rules_library(
1213 self.language_registry.clone(),
1214 Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1215 Rc::new(|| {
1216 Rc::new(SlashCommandCompletionProvider::new(
1217 Arc::new(SlashCommandWorkingSet::default()),
1218 None,
1219 None,
1220 ))
1221 }),
1222 action
1223 .prompt_to_select
1224 .map(|uuid| UserPromptId(uuid).into()),
1225 cx,
1226 )
1227 .detach_and_log_err(cx);
1228 }
1229
1230 fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1231 let Some(thread_view) = self.active_thread_view() else {
1232 return;
1233 };
1234
1235 let Some(active_thread) = thread_view.read(cx).active_thread().cloned() else {
1236 return;
1237 };
1238
1239 active_thread.update(cx, |active_thread, cx| {
1240 active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1241 active_thread.focus_handle(cx).focus(window, cx);
1242 })
1243 }
1244
1245 fn history_kind_for_selected_agent(&self, cx: &App) -> Option<HistoryKind> {
1246 match self.selected_agent {
1247 AgentType::NativeAgent => Some(HistoryKind::AgentThreads),
1248 AgentType::TextThread => Some(HistoryKind::TextThreads),
1249 AgentType::Custom { .. } => {
1250 if self.acp_history.read(cx).has_session_list() {
1251 Some(HistoryKind::AgentThreads)
1252 } else {
1253 None
1254 }
1255 }
1256 }
1257 }
1258
1259 fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1260 let Some(kind) = self.history_kind_for_selected_agent(cx) else {
1261 return;
1262 };
1263
1264 if let ActiveView::History { kind: active_kind } = self.active_view {
1265 if active_kind == kind {
1266 if let Some(previous_view) = self.previous_view.take() {
1267 self.set_active_view(previous_view, true, window, cx);
1268 }
1269 return;
1270 }
1271 }
1272
1273 self.set_active_view(ActiveView::History { kind }, true, window, cx);
1274 cx.notify();
1275 }
1276
1277 pub(crate) fn open_saved_text_thread(
1278 &mut self,
1279 path: Arc<Path>,
1280 window: &mut Window,
1281 cx: &mut Context<Self>,
1282 ) -> Task<Result<()>> {
1283 let text_thread_task = self
1284 .text_thread_store
1285 .update(cx, |store, cx| store.open_local(path, cx));
1286 cx.spawn_in(window, async move |this, cx| {
1287 let text_thread = text_thread_task.await?;
1288 this.update_in(cx, |this, window, cx| {
1289 this.open_text_thread(text_thread, window, cx);
1290 })
1291 })
1292 }
1293
1294 pub(crate) fn open_text_thread(
1295 &mut self,
1296 text_thread: Entity<TextThread>,
1297 window: &mut Window,
1298 cx: &mut Context<Self>,
1299 ) {
1300 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project.clone(), cx)
1301 .log_err()
1302 .flatten();
1303 let editor = cx.new(|cx| {
1304 TextThreadEditor::for_text_thread(
1305 text_thread,
1306 self.fs.clone(),
1307 self.workspace.clone(),
1308 self.project.clone(),
1309 lsp_adapter_delegate,
1310 window,
1311 cx,
1312 )
1313 });
1314
1315 if self.selected_agent != AgentType::TextThread {
1316 self.selected_agent = AgentType::TextThread;
1317 self.serialize(cx);
1318 }
1319
1320 self.set_active_view(
1321 ActiveView::text_thread(editor, self.language_registry.clone(), window, cx),
1322 true,
1323 window,
1324 cx,
1325 );
1326 }
1327
1328 pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1329 match self.active_view {
1330 ActiveView::Configuration | ActiveView::History { .. } => {
1331 if let Some(previous_view) = self.previous_view.take() {
1332 self.set_active_view(previous_view, true, window, cx);
1333 }
1334 cx.notify();
1335 }
1336 _ => {}
1337 }
1338 }
1339
1340 pub fn toggle_navigation_menu(
1341 &mut self,
1342 _: &ToggleNavigationMenu,
1343 window: &mut Window,
1344 cx: &mut Context<Self>,
1345 ) {
1346 if self.history_kind_for_selected_agent(cx).is_none() {
1347 return;
1348 }
1349 self.agent_navigation_menu_handle.toggle(window, cx);
1350 }
1351
1352 pub fn toggle_options_menu(
1353 &mut self,
1354 _: &ToggleOptionsMenu,
1355 window: &mut Window,
1356 cx: &mut Context<Self>,
1357 ) {
1358 self.agent_panel_menu_handle.toggle(window, cx);
1359 }
1360
1361 pub fn toggle_new_thread_menu(
1362 &mut self,
1363 _: &ToggleNewThreadMenu,
1364 window: &mut Window,
1365 cx: &mut Context<Self>,
1366 ) {
1367 self.new_thread_menu_handle.toggle(window, cx);
1368 }
1369
1370 pub fn increase_font_size(
1371 &mut self,
1372 action: &IncreaseBufferFontSize,
1373 _: &mut Window,
1374 cx: &mut Context<Self>,
1375 ) {
1376 self.handle_font_size_action(action.persist, px(1.0), cx);
1377 }
1378
1379 pub fn decrease_font_size(
1380 &mut self,
1381 action: &DecreaseBufferFontSize,
1382 _: &mut Window,
1383 cx: &mut Context<Self>,
1384 ) {
1385 self.handle_font_size_action(action.persist, px(-1.0), cx);
1386 }
1387
1388 fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1389 match self.active_view.which_font_size_used() {
1390 WhichFontSize::AgentFont => {
1391 if persist {
1392 update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1393 let agent_ui_font_size =
1394 ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1395 let agent_buffer_font_size =
1396 ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1397
1398 let _ = settings
1399 .theme
1400 .agent_ui_font_size
1401 .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into());
1402 let _ = settings.theme.agent_buffer_font_size.insert(
1403 f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(),
1404 );
1405 });
1406 } else {
1407 theme::adjust_agent_ui_font_size(cx, |size| size + delta);
1408 theme::adjust_agent_buffer_font_size(cx, |size| size + delta);
1409 }
1410 }
1411 WhichFontSize::BufferFont => {
1412 // Prompt editor uses the buffer font size, so allow the action to propagate to the
1413 // default handler that changes that font size.
1414 cx.propagate();
1415 }
1416 WhichFontSize::None => {}
1417 }
1418 }
1419
1420 pub fn reset_font_size(
1421 &mut self,
1422 action: &ResetBufferFontSize,
1423 _: &mut Window,
1424 cx: &mut Context<Self>,
1425 ) {
1426 if action.persist {
1427 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1428 settings.theme.agent_ui_font_size = None;
1429 settings.theme.agent_buffer_font_size = None;
1430 });
1431 } else {
1432 theme::reset_agent_ui_font_size(cx);
1433 theme::reset_agent_buffer_font_size(cx);
1434 }
1435 }
1436
1437 pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1438 theme::reset_agent_ui_font_size(cx);
1439 theme::reset_agent_buffer_font_size(cx);
1440 }
1441
1442 pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1443 if self.zoomed {
1444 cx.emit(PanelEvent::ZoomOut);
1445 } else {
1446 if !self.focus_handle(cx).contains_focused(window, cx) {
1447 cx.focus_self(window);
1448 }
1449 cx.emit(PanelEvent::ZoomIn);
1450 }
1451 }
1452
1453 pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1454 let agent_server_store = self.project.read(cx).agent_server_store().clone();
1455 let context_server_store = self.project.read(cx).context_server_store();
1456 let fs = self.fs.clone();
1457
1458 self.set_active_view(ActiveView::Configuration, true, window, cx);
1459 self.configuration = Some(cx.new(|cx| {
1460 AgentConfiguration::new(
1461 fs,
1462 agent_server_store,
1463 context_server_store,
1464 self.context_server_registry.clone(),
1465 self.language_registry.clone(),
1466 self.workspace.clone(),
1467 window,
1468 cx,
1469 )
1470 }));
1471
1472 if let Some(configuration) = self.configuration.as_ref() {
1473 self.configuration_subscription = Some(cx.subscribe_in(
1474 configuration,
1475 window,
1476 Self::handle_agent_configuration_event,
1477 ));
1478
1479 configuration.focus_handle(cx).focus(window, cx);
1480 }
1481 }
1482
1483 pub(crate) fn open_active_thread_as_markdown(
1484 &mut self,
1485 _: &OpenActiveThreadAsMarkdown,
1486 window: &mut Window,
1487 cx: &mut Context<Self>,
1488 ) {
1489 if let Some(workspace) = self.workspace.upgrade()
1490 && let Some(thread_view) = self.active_thread_view()
1491 && let Some(active_thread) = thread_view.read(cx).active_thread().cloned()
1492 {
1493 active_thread.update(cx, |thread, cx| {
1494 thread
1495 .open_thread_as_markdown(workspace, window, cx)
1496 .detach_and_log_err(cx);
1497 });
1498 }
1499 }
1500
1501 fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1502 let Some(thread) = self.active_native_agent_thread(cx) else {
1503 Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1504 return;
1505 };
1506
1507 let workspace = self.workspace.clone();
1508 let load_task = thread.read(cx).to_db(cx);
1509
1510 cx.spawn_in(window, async move |_this, cx| {
1511 let db_thread = load_task.await;
1512 let shared_thread = SharedThread::from_db_thread(&db_thread);
1513 let thread_data = shared_thread.to_bytes()?;
1514 let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1515
1516 cx.update(|_window, cx| {
1517 cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1518 if let Some(workspace) = workspace.upgrade() {
1519 workspace.update(cx, |workspace, cx| {
1520 struct ThreadCopiedToast;
1521 workspace.show_toast(
1522 workspace::Toast::new(
1523 workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1524 "Thread copied to clipboard (base64 encoded)",
1525 )
1526 .autohide(),
1527 cx,
1528 );
1529 });
1530 }
1531 })?;
1532
1533 anyhow::Ok(())
1534 })
1535 .detach_and_log_err(cx);
1536 }
1537
1538 fn show_deferred_toast(
1539 workspace: &WeakEntity<workspace::Workspace>,
1540 message: &'static str,
1541 cx: &mut App,
1542 ) {
1543 let workspace = workspace.clone();
1544 cx.defer(move |cx| {
1545 if let Some(workspace) = workspace.upgrade() {
1546 workspace.update(cx, |workspace, cx| {
1547 struct ClipboardToast;
1548 workspace.show_toast(
1549 workspace::Toast::new(
1550 workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1551 message,
1552 )
1553 .autohide(),
1554 cx,
1555 );
1556 });
1557 }
1558 });
1559 }
1560
1561 fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1562 let Some(clipboard) = cx.read_from_clipboard() else {
1563 Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1564 return;
1565 };
1566
1567 let Some(encoded) = clipboard.text() else {
1568 Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1569 return;
1570 };
1571
1572 let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1573 {
1574 Ok(data) => data,
1575 Err(_) => {
1576 Self::show_deferred_toast(
1577 &self.workspace,
1578 "Failed to decode clipboard content (expected base64)",
1579 cx,
1580 );
1581 return;
1582 }
1583 };
1584
1585 let shared_thread = match SharedThread::from_bytes(&thread_data) {
1586 Ok(thread) => thread,
1587 Err(_) => {
1588 Self::show_deferred_toast(
1589 &self.workspace,
1590 "Failed to parse thread data from clipboard",
1591 cx,
1592 );
1593 return;
1594 }
1595 };
1596
1597 let db_thread = shared_thread.to_db_thread();
1598 let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1599 let thread_store = self.thread_store.clone();
1600 let title = db_thread.title.clone();
1601 let workspace = self.workspace.clone();
1602
1603 cx.spawn_in(window, async move |this, cx| {
1604 thread_store
1605 .update(&mut cx.clone(), |store, cx| {
1606 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1607 })
1608 .await?;
1609
1610 let thread_metadata = acp_thread::AgentSessionInfo {
1611 session_id,
1612 cwd: None,
1613 title: Some(title),
1614 updated_at: Some(chrono::Utc::now()),
1615 meta: None,
1616 };
1617
1618 this.update_in(cx, |this, window, cx| {
1619 this.open_thread(thread_metadata, window, cx);
1620 })?;
1621
1622 this.update_in(cx, |_, _window, cx| {
1623 if let Some(workspace) = workspace.upgrade() {
1624 workspace.update(cx, |workspace, cx| {
1625 struct ThreadLoadedToast;
1626 workspace.show_toast(
1627 workspace::Toast::new(
1628 workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1629 "Thread loaded from clipboard",
1630 )
1631 .autohide(),
1632 cx,
1633 );
1634 });
1635 }
1636 })?;
1637
1638 anyhow::Ok(())
1639 })
1640 .detach_and_log_err(cx);
1641 }
1642
1643 fn handle_agent_configuration_event(
1644 &mut self,
1645 _entity: &Entity<AgentConfiguration>,
1646 event: &AssistantConfigurationEvent,
1647 window: &mut Window,
1648 cx: &mut Context<Self>,
1649 ) {
1650 match event {
1651 AssistantConfigurationEvent::NewThread(provider) => {
1652 if LanguageModelRegistry::read_global(cx)
1653 .default_model()
1654 .is_none_or(|model| model.provider.id() != provider.id())
1655 && let Some(model) = provider.default_model(cx)
1656 {
1657 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1658 let provider = model.provider_id().0.to_string();
1659 let enable_thinking = model.supports_thinking();
1660 let effort = model
1661 .default_effort_level()
1662 .map(|effort| effort.value.to_string());
1663 let model = model.id().0.to_string();
1664 settings
1665 .agent
1666 .get_or_insert_default()
1667 .set_model(LanguageModelSelection {
1668 provider: LanguageModelProviderSetting(provider),
1669 model,
1670 enable_thinking,
1671 effort,
1672 })
1673 });
1674 }
1675
1676 self.new_thread(&NewThread, window, cx);
1677 if let Some((thread, model)) = self
1678 .active_native_agent_thread(cx)
1679 .zip(provider.default_model(cx))
1680 {
1681 thread.update(cx, |thread, cx| {
1682 thread.set_model(model, cx);
1683 });
1684 }
1685 }
1686 }
1687 }
1688
1689 pub fn as_active_server_view(&self) -> Option<&Entity<ConnectionView>> {
1690 match &self.active_view {
1691 ActiveView::AgentThread { server_view } => Some(server_view),
1692 _ => None,
1693 }
1694 }
1695
1696 pub fn as_active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
1697 let server_view = self.as_active_server_view()?;
1698 server_view.read(cx).active_thread().cloned()
1699 }
1700
1701 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1702 match &self.active_view {
1703 ActiveView::AgentThread { server_view, .. } => server_view
1704 .read(cx)
1705 .active_thread()
1706 .map(|r| r.read(cx).thread.clone()),
1707 _ => None,
1708 }
1709 }
1710
1711 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
1712 match &self.active_view {
1713 ActiveView::AgentThread { server_view, .. } => {
1714 server_view.read(cx).as_native_thread(cx)
1715 }
1716 _ => None,
1717 }
1718 }
1719
1720 pub(crate) fn active_text_thread_editor(&self) -> Option<Entity<TextThreadEditor>> {
1721 match &self.active_view {
1722 ActiveView::TextThread {
1723 text_thread_editor, ..
1724 } => Some(text_thread_editor.clone()),
1725 _ => None,
1726 }
1727 }
1728
1729 fn set_active_view(
1730 &mut self,
1731 new_view: ActiveView,
1732 focus: bool,
1733 window: &mut Window,
1734 cx: &mut Context<Self>,
1735 ) {
1736 let was_in_agent_history = matches!(
1737 self.active_view,
1738 ActiveView::History {
1739 kind: HistoryKind::AgentThreads
1740 }
1741 );
1742 let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
1743 let current_is_history = matches!(self.active_view, ActiveView::History { .. });
1744 let new_is_history = matches!(new_view, ActiveView::History { .. });
1745
1746 let current_is_config = matches!(self.active_view, ActiveView::Configuration);
1747 let new_is_config = matches!(new_view, ActiveView::Configuration);
1748
1749 let current_is_special = current_is_history || current_is_config;
1750 let new_is_special = new_is_history || new_is_config;
1751
1752 if current_is_uninitialized || (current_is_special && !new_is_special) {
1753 self.active_view = new_view;
1754 } else if !current_is_special && new_is_special {
1755 self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
1756 } else {
1757 if !new_is_special {
1758 self.previous_view = None;
1759 }
1760 self.active_view = new_view;
1761 }
1762
1763 // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
1764 // so the panel can intercept the first send for worktree creation.
1765 // Re-subscribe whenever the ConnectionView changes, since the inner
1766 // ThreadView may have been replaced (e.g. navigating between threads).
1767 self._active_view_observation = match &self.active_view {
1768 ActiveView::AgentThread { server_view } => {
1769 self._thread_view_subscription =
1770 Self::subscribe_to_active_thread_view(server_view, window, cx);
1771 Some(
1772 cx.observe_in(server_view, window, |this, server_view, window, cx| {
1773 this._thread_view_subscription =
1774 Self::subscribe_to_active_thread_view(&server_view, window, cx);
1775 cx.emit(AgentPanelEvent::ActiveViewChanged);
1776 this.serialize(cx);
1777 cx.notify();
1778 }),
1779 )
1780 }
1781 _ => {
1782 self._thread_view_subscription = None;
1783 None
1784 }
1785 };
1786
1787 let is_in_agent_history = matches!(
1788 self.active_view,
1789 ActiveView::History {
1790 kind: HistoryKind::AgentThreads
1791 }
1792 );
1793
1794 if !was_in_agent_history && is_in_agent_history {
1795 self.acp_history
1796 .update(cx, |history, cx| history.refresh_full_history(cx));
1797 }
1798
1799 if focus {
1800 self.focus_handle(cx).focus(window, cx);
1801 }
1802 cx.emit(AgentPanelEvent::ActiveViewChanged);
1803 }
1804
1805 fn populate_recently_updated_menu_section(
1806 mut menu: ContextMenu,
1807 panel: Entity<Self>,
1808 kind: HistoryKind,
1809 cx: &mut Context<ContextMenu>,
1810 ) -> ContextMenu {
1811 match kind {
1812 HistoryKind::AgentThreads => {
1813 let entries = panel
1814 .read(cx)
1815 .acp_history
1816 .read(cx)
1817 .sessions()
1818 .iter()
1819 .take(RECENTLY_UPDATED_MENU_LIMIT)
1820 .cloned()
1821 .collect::<Vec<_>>();
1822
1823 if entries.is_empty() {
1824 return menu;
1825 }
1826
1827 menu = menu.header("Recently Updated");
1828
1829 for entry in entries {
1830 let title = entry
1831 .title
1832 .as_ref()
1833 .filter(|title| !title.is_empty())
1834 .cloned()
1835 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
1836
1837 menu = menu.entry(title, None, {
1838 let panel = panel.downgrade();
1839 let entry = entry.clone();
1840 move |window, cx| {
1841 let entry = entry.clone();
1842 panel
1843 .update(cx, move |this, cx| {
1844 this.load_agent_thread(entry.clone(), window, cx);
1845 })
1846 .ok();
1847 }
1848 });
1849 }
1850 }
1851 HistoryKind::TextThreads => {
1852 let entries = panel
1853 .read(cx)
1854 .text_thread_store
1855 .read(cx)
1856 .ordered_text_threads()
1857 .take(RECENTLY_UPDATED_MENU_LIMIT)
1858 .cloned()
1859 .collect::<Vec<_>>();
1860
1861 if entries.is_empty() {
1862 return menu;
1863 }
1864
1865 menu = menu.header("Recent Text Threads");
1866
1867 for entry in entries {
1868 let title = if entry.title.is_empty() {
1869 SharedString::new_static(DEFAULT_THREAD_TITLE)
1870 } else {
1871 entry.title.clone()
1872 };
1873
1874 menu = menu.entry(title, None, {
1875 let panel = panel.downgrade();
1876 let entry = entry.clone();
1877 move |window, cx| {
1878 let path = entry.path.clone();
1879 panel
1880 .update(cx, move |this, cx| {
1881 this.open_saved_text_thread(path.clone(), window, cx)
1882 .detach_and_log_err(cx);
1883 })
1884 .ok();
1885 }
1886 });
1887 }
1888 }
1889 }
1890
1891 menu.separator()
1892 }
1893
1894 pub fn selected_agent(&self) -> AgentType {
1895 self.selected_agent.clone()
1896 }
1897
1898 fn subscribe_to_active_thread_view(
1899 server_view: &Entity<ConnectionView>,
1900 window: &mut Window,
1901 cx: &mut Context<Self>,
1902 ) -> Option<Subscription> {
1903 server_view.read(cx).active_thread().cloned().map(|tv| {
1904 cx.subscribe_in(
1905 &tv,
1906 window,
1907 |this, view, event: &AcpThreadViewEvent, window, cx| match event {
1908 AcpThreadViewEvent::FirstSendRequested { content } => {
1909 this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
1910 }
1911 },
1912 )
1913 })
1914 }
1915
1916 pub fn start_thread_in(&self) -> &StartThreadIn {
1917 &self.start_thread_in
1918 }
1919
1920 fn set_start_thread_in(&mut self, action: &StartThreadIn, cx: &mut Context<Self>) {
1921 if matches!(action, StartThreadIn::NewWorktree)
1922 && !cx.has_flag::<AgentGitWorktreesFeatureFlag>()
1923 {
1924 return;
1925 }
1926
1927 let new_target = match *action {
1928 StartThreadIn::LocalProject => StartThreadIn::LocalProject,
1929 StartThreadIn::NewWorktree => {
1930 if !self.project_has_git_repository(cx) {
1931 log::error!(
1932 "set_start_thread_in: cannot use NewWorktree without a git repository"
1933 );
1934 return;
1935 }
1936 if self.project.read(cx).is_via_collab() {
1937 log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
1938 return;
1939 }
1940 StartThreadIn::NewWorktree
1941 }
1942 };
1943 self.start_thread_in = new_target;
1944 self.serialize(cx);
1945 cx.notify();
1946 }
1947
1948 fn selected_external_agent(&self) -> Option<ExternalAgent> {
1949 match &self.selected_agent {
1950 AgentType::NativeAgent => Some(ExternalAgent::NativeAgent),
1951 AgentType::Custom { name } => Some(ExternalAgent::Custom { name: name.clone() }),
1952 AgentType::TextThread => None,
1953 }
1954 }
1955
1956 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
1957 if let Some(extension_store) = ExtensionStore::try_global(cx) {
1958 let (manifests, extensions_dir) = {
1959 let store = extension_store.read(cx);
1960 let installed = store.installed_extensions();
1961 let manifests: Vec<_> = installed
1962 .iter()
1963 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
1964 .collect();
1965 let extensions_dir = paths::extensions_dir().join("installed");
1966 (manifests, extensions_dir)
1967 };
1968
1969 self.project.update(cx, |project, cx| {
1970 project.agent_server_store().update(cx, |store, cx| {
1971 let manifest_refs: Vec<_> = manifests
1972 .iter()
1973 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
1974 .collect();
1975 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
1976 });
1977 });
1978 }
1979 }
1980
1981 pub fn new_external_thread_with_text(
1982 &mut self,
1983 initial_text: Option<String>,
1984 window: &mut Window,
1985 cx: &mut Context<Self>,
1986 ) {
1987 self.external_thread(
1988 None,
1989 None,
1990 initial_text.map(|text| AgentInitialContent::ContentBlock {
1991 blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
1992 auto_submit: false,
1993 }),
1994 window,
1995 cx,
1996 );
1997 }
1998
1999 pub fn new_agent_thread(
2000 &mut self,
2001 agent: AgentType,
2002 window: &mut Window,
2003 cx: &mut Context<Self>,
2004 ) {
2005 match agent {
2006 AgentType::TextThread => {
2007 window.dispatch_action(NewTextThread.boxed_clone(), cx);
2008 }
2009 AgentType::NativeAgent => self.external_thread(
2010 Some(crate::ExternalAgent::NativeAgent),
2011 None,
2012 None,
2013 window,
2014 cx,
2015 ),
2016 AgentType::Custom { name } => self.external_thread(
2017 Some(crate::ExternalAgent::Custom { name }),
2018 None,
2019 None,
2020 window,
2021 cx,
2022 ),
2023 }
2024 }
2025
2026 pub fn load_agent_thread(
2027 &mut self,
2028 thread: AgentSessionInfo,
2029 window: &mut Window,
2030 cx: &mut Context<Self>,
2031 ) {
2032 let Some(agent) = self.selected_external_agent() else {
2033 return;
2034 };
2035 self.external_thread(Some(agent), Some(thread), None, window, cx);
2036 }
2037
2038 pub(crate) fn create_external_thread(
2039 &mut self,
2040 server: Rc<dyn AgentServer>,
2041 resume_thread: Option<AgentSessionInfo>,
2042 initial_content: Option<AgentInitialContent>,
2043 workspace: WeakEntity<Workspace>,
2044 project: Entity<Project>,
2045 ext_agent: ExternalAgent,
2046 window: &mut Window,
2047 cx: &mut Context<Self>,
2048 ) {
2049 let selected_agent = AgentType::from(ext_agent);
2050 if self.selected_agent != selected_agent {
2051 self.selected_agent = selected_agent;
2052 self.serialize(cx);
2053 }
2054 let thread_store = server
2055 .clone()
2056 .downcast::<agent::NativeAgentServer>()
2057 .is_some()
2058 .then(|| self.thread_store.clone());
2059
2060 let server_view = cx.new(|cx| {
2061 crate::ConnectionView::new(
2062 server,
2063 resume_thread,
2064 initial_content,
2065 workspace.clone(),
2066 project,
2067 thread_store,
2068 self.prompt_store.clone(),
2069 self.acp_history.clone(),
2070 window,
2071 cx,
2072 )
2073 });
2074
2075 self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx);
2076 }
2077
2078 fn active_thread_has_messages(&self, cx: &App) -> bool {
2079 self.active_agent_thread(cx)
2080 .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2081 }
2082
2083 fn handle_first_send_requested(
2084 &mut self,
2085 thread_view: Entity<ThreadView>,
2086 content: Vec<acp::ContentBlock>,
2087 window: &mut Window,
2088 cx: &mut Context<Self>,
2089 ) {
2090 if self.start_thread_in == StartThreadIn::NewWorktree {
2091 self.handle_worktree_creation_requested(content, window, cx);
2092 } else {
2093 cx.defer_in(window, move |_this, window, cx| {
2094 thread_view.update(cx, |thread_view, cx| {
2095 let editor = thread_view.message_editor.clone();
2096 thread_view.send_impl(editor, window, cx);
2097 });
2098 });
2099 }
2100 }
2101
2102 fn generate_agent_branch_name() -> String {
2103 let mut rng = rand::rng();
2104 let id: String = (0..8)
2105 .map(|_| {
2106 let idx: u8 = rng.random_range(0..36);
2107 if idx < 10 {
2108 (b'0' + idx) as char
2109 } else {
2110 (b'a' + idx - 10) as char
2111 }
2112 })
2113 .collect();
2114 format!("agent-{id}")
2115 }
2116
2117 /// Partitions the project's visible worktrees into git-backed repositories
2118 /// and plain (non-git) paths. Git repos will have worktrees created for
2119 /// them; non-git paths are carried over to the new workspace as-is.
2120 ///
2121 /// When multiple worktrees map to the same repository, the most specific
2122 /// match wins (deepest work directory path), with a deterministic
2123 /// tie-break on entity id. Each repository appears at most once.
2124 fn classify_worktrees(
2125 &self,
2126 cx: &App,
2127 ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2128 let project = &self.project;
2129 let repositories = project.read(cx).repositories(cx).clone();
2130 let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2131 let mut non_git_paths: Vec<PathBuf> = Vec::new();
2132 let mut seen_repo_ids = std::collections::HashSet::new();
2133
2134 for worktree in project.read(cx).visible_worktrees(cx) {
2135 let wt_path = worktree.read(cx).abs_path();
2136
2137 let matching_repo = repositories
2138 .iter()
2139 .filter_map(|(id, repo)| {
2140 let work_dir = repo.read(cx).work_directory_abs_path.clone();
2141 if wt_path.starts_with(work_dir.as_ref())
2142 || work_dir.starts_with(wt_path.as_ref())
2143 {
2144 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2145 } else {
2146 None
2147 }
2148 })
2149 .max_by(
2150 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2151 left_depth
2152 .cmp(right_depth)
2153 .then_with(|| left_id.cmp(right_id))
2154 },
2155 );
2156
2157 if let Some((id, repo, _)) = matching_repo {
2158 if seen_repo_ids.insert(id) {
2159 git_repos.push(repo);
2160 }
2161 } else {
2162 non_git_paths.push(wt_path.to_path_buf());
2163 }
2164 }
2165
2166 (git_repos, non_git_paths)
2167 }
2168
2169 /// Kicks off an async git-worktree creation for each repository. Returns:
2170 ///
2171 /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2172 /// receiver resolves once the git worktree command finishes.
2173 /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2174 /// later to remap open editor tabs into the new workspace.
2175 fn start_worktree_creations(
2176 git_repos: &[Entity<project::git_store::Repository>],
2177 branch_name: &str,
2178 worktree_directory_setting: &str,
2179 cx: &mut Context<Self>,
2180 ) -> Result<(
2181 Vec<(
2182 Entity<project::git_store::Repository>,
2183 PathBuf,
2184 futures::channel::oneshot::Receiver<Result<()>>,
2185 )>,
2186 Vec<(PathBuf, PathBuf)>,
2187 )> {
2188 let mut creation_infos = Vec::new();
2189 let mut path_remapping = Vec::new();
2190
2191 for repo in git_repos {
2192 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2193 let original_repo = repo.original_repo_abs_path.clone();
2194 let directory =
2195 validate_worktree_directory(&original_repo, worktree_directory_setting)?;
2196 let new_path = directory.join(branch_name);
2197 let receiver = repo.create_worktree(branch_name.to_string(), directory, None);
2198 let work_dir = repo.work_directory_abs_path.clone();
2199 anyhow::Ok((work_dir, new_path, receiver))
2200 })?;
2201 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2202 creation_infos.push((repo.clone(), new_path, receiver));
2203 }
2204
2205 Ok((creation_infos, path_remapping))
2206 }
2207
2208 /// Waits for every in-flight worktree creation to complete. If any
2209 /// creation fails, all successfully-created worktrees are rolled back
2210 /// (removed) so the project isn't left in a half-migrated state.
2211 async fn await_and_rollback_on_failure(
2212 creation_infos: Vec<(
2213 Entity<project::git_store::Repository>,
2214 PathBuf,
2215 futures::channel::oneshot::Receiver<Result<()>>,
2216 )>,
2217 cx: &mut AsyncWindowContext,
2218 ) -> Result<Vec<PathBuf>> {
2219 let mut created_paths: Vec<PathBuf> = Vec::new();
2220 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2221 Vec::new();
2222 let mut first_error: Option<anyhow::Error> = None;
2223
2224 for (repo, new_path, receiver) in creation_infos {
2225 match receiver.await {
2226 Ok(Ok(())) => {
2227 created_paths.push(new_path.clone());
2228 repos_and_paths.push((repo, new_path));
2229 }
2230 Ok(Err(err)) => {
2231 if first_error.is_none() {
2232 first_error = Some(err);
2233 }
2234 }
2235 Err(_canceled) => {
2236 if first_error.is_none() {
2237 first_error = Some(anyhow!("Worktree creation was canceled"));
2238 }
2239 }
2240 }
2241 }
2242
2243 let Some(err) = first_error else {
2244 return Ok(created_paths);
2245 };
2246
2247 // Rollback all successfully created worktrees
2248 let mut rollback_receivers = Vec::new();
2249 for (rollback_repo, rollback_path) in &repos_and_paths {
2250 if let Ok(receiver) = cx.update(|_, cx| {
2251 rollback_repo.update(cx, |repo, _cx| {
2252 repo.remove_worktree(rollback_path.clone(), true)
2253 })
2254 }) {
2255 rollback_receivers.push((rollback_path.clone(), receiver));
2256 }
2257 }
2258 let mut rollback_failures: Vec<String> = Vec::new();
2259 for (path, receiver) in rollback_receivers {
2260 match receiver.await {
2261 Ok(Ok(())) => {}
2262 Ok(Err(rollback_err)) => {
2263 log::error!(
2264 "failed to rollback worktree at {}: {rollback_err}",
2265 path.display()
2266 );
2267 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2268 }
2269 Err(rollback_err) => {
2270 log::error!(
2271 "failed to rollback worktree at {}: {rollback_err}",
2272 path.display()
2273 );
2274 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2275 }
2276 }
2277 }
2278 let mut error_message = format!("Failed to create worktree: {err}");
2279 if !rollback_failures.is_empty() {
2280 error_message.push_str("\n\nFailed to clean up: ");
2281 error_message.push_str(&rollback_failures.join(", "));
2282 }
2283 Err(anyhow!(error_message))
2284 }
2285
2286 fn set_worktree_creation_error(
2287 &mut self,
2288 message: SharedString,
2289 window: &mut Window,
2290 cx: &mut Context<Self>,
2291 ) {
2292 self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
2293 if matches!(self.active_view, ActiveView::Uninitialized) {
2294 let selected_agent = self.selected_agent.clone();
2295 self.new_agent_thread(selected_agent, window, cx);
2296 }
2297 cx.notify();
2298 }
2299
2300 fn handle_worktree_creation_requested(
2301 &mut self,
2302 content: Vec<acp::ContentBlock>,
2303 window: &mut Window,
2304 cx: &mut Context<Self>,
2305 ) {
2306 if matches!(
2307 self.worktree_creation_status,
2308 Some(WorktreeCreationStatus::Creating)
2309 ) {
2310 return;
2311 }
2312
2313 self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
2314 cx.notify();
2315
2316 let branch_name = Self::generate_agent_branch_name();
2317
2318 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
2319
2320 if git_repos.is_empty() {
2321 self.set_worktree_creation_error(
2322 "No git repositories found in the project".into(),
2323 window,
2324 cx,
2325 );
2326 return;
2327 }
2328
2329 let worktree_directory_setting = ProjectSettings::get_global(cx)
2330 .git
2331 .worktree_directory
2332 .clone();
2333
2334 let (creation_infos, path_remapping) = match Self::start_worktree_creations(
2335 &git_repos,
2336 &branch_name,
2337 &worktree_directory_setting,
2338 cx,
2339 ) {
2340 Ok(result) => result,
2341 Err(err) => {
2342 self.set_worktree_creation_error(
2343 format!("Failed to validate worktree directory: {err}").into(),
2344 window,
2345 cx,
2346 );
2347 return;
2348 }
2349 };
2350
2351 let (dock_structure, open_file_paths) = self
2352 .workspace
2353 .upgrade()
2354 .map(|workspace| {
2355 let dock_structure = workspace.read(cx).capture_dock_state(window, cx);
2356 let open_file_paths = workspace.read(cx).open_item_abs_paths(cx);
2357 (dock_structure, open_file_paths)
2358 })
2359 .unwrap_or_default();
2360
2361 let workspace = self.workspace.clone();
2362 let window_handle = window
2363 .window_handle()
2364 .downcast::<workspace::MultiWorkspace>();
2365
2366 let task = cx.spawn_in(window, async move |this, cx| {
2367 let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
2368 {
2369 Ok(paths) => paths,
2370 Err(err) => {
2371 this.update_in(cx, |this, window, cx| {
2372 this.set_worktree_creation_error(format!("{err}").into(), window, cx);
2373 })?;
2374 return anyhow::Ok(());
2375 }
2376 };
2377
2378 let mut all_paths = created_paths;
2379 let has_non_git = !non_git_paths.is_empty();
2380 all_paths.extend(non_git_paths.iter().cloned());
2381
2382 let app_state = match workspace.upgrade() {
2383 Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
2384 None => {
2385 this.update_in(cx, |this, window, cx| {
2386 this.set_worktree_creation_error(
2387 "Workspace no longer available".into(),
2388 window,
2389 cx,
2390 );
2391 })?;
2392 return anyhow::Ok(());
2393 }
2394 };
2395
2396 let this_for_error = this.clone();
2397 if let Err(err) = Self::setup_new_workspace(
2398 this,
2399 all_paths,
2400 app_state,
2401 window_handle,
2402 dock_structure,
2403 open_file_paths,
2404 path_remapping,
2405 non_git_paths,
2406 has_non_git,
2407 content,
2408 cx,
2409 )
2410 .await
2411 {
2412 this_for_error
2413 .update_in(cx, |this, window, cx| {
2414 this.set_worktree_creation_error(
2415 format!("Failed to set up workspace: {err}").into(),
2416 window,
2417 cx,
2418 );
2419 })
2420 .log_err();
2421 }
2422 anyhow::Ok(())
2423 });
2424
2425 self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move {
2426 task.await.log_err();
2427 }));
2428 }
2429
2430 async fn setup_new_workspace(
2431 this: WeakEntity<Self>,
2432 all_paths: Vec<PathBuf>,
2433 app_state: Arc<workspace::AppState>,
2434 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
2435 dock_structure: workspace::DockStructure,
2436 open_file_paths: Vec<PathBuf>,
2437 path_remapping: Vec<(PathBuf, PathBuf)>,
2438 non_git_paths: Vec<PathBuf>,
2439 has_non_git: bool,
2440 content: Vec<acp::ContentBlock>,
2441 cx: &mut AsyncWindowContext,
2442 ) -> Result<()> {
2443 let init: Option<
2444 Box<dyn FnOnce(&mut Workspace, &mut Window, &mut gpui::Context<Workspace>) + Send>,
2445 > = Some(Box::new(move |workspace, window, cx| {
2446 workspace.set_dock_structure(dock_structure, window, cx);
2447 }));
2448
2449 let (new_window_handle, _) = cx
2450 .update(|_window, cx| {
2451 Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx)
2452 })?
2453 .await?;
2454
2455 let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| {
2456 let workspaces = multi_workspace.workspaces();
2457 workspaces.last().cloned()
2458 })?;
2459
2460 let Some(new_workspace) = new_workspace else {
2461 anyhow::bail!("New workspace was not added to MultiWorkspace");
2462 };
2463
2464 let panels_task = new_window_handle.update(cx, |_, _, cx| {
2465 new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task())
2466 })?;
2467 if let Some(task) = panels_task {
2468 task.await.log_err();
2469 }
2470
2471 let initial_content = AgentInitialContent::ContentBlock {
2472 blocks: content,
2473 auto_submit: true,
2474 };
2475
2476 new_window_handle.update(cx, |_multi_workspace, window, cx| {
2477 new_workspace.update(cx, |workspace, cx| {
2478 if has_non_git {
2479 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
2480 workspace.show_toast(
2481 workspace::Toast::new(
2482 toast_id,
2483 "Some project folders are not git repositories. \
2484 They were included as-is without creating a worktree.",
2485 ),
2486 cx,
2487 );
2488 }
2489
2490 let remapped_paths: Vec<PathBuf> = open_file_paths
2491 .iter()
2492 .filter_map(|original_path| {
2493 let best_match = path_remapping
2494 .iter()
2495 .filter_map(|(old_root, new_root)| {
2496 original_path.strip_prefix(old_root).ok().map(|relative| {
2497 (old_root.components().count(), new_root.join(relative))
2498 })
2499 })
2500 .max_by_key(|(depth, _)| *depth);
2501
2502 if let Some((_, remapped_path)) = best_match {
2503 return Some(remapped_path);
2504 }
2505
2506 for non_git in &non_git_paths {
2507 if original_path.starts_with(non_git) {
2508 return Some(original_path.clone());
2509 }
2510 }
2511 None
2512 })
2513 .collect();
2514
2515 if !remapped_paths.is_empty() {
2516 workspace
2517 .open_paths(
2518 remapped_paths,
2519 workspace::OpenOptions::default(),
2520 None,
2521 window,
2522 cx,
2523 )
2524 .detach();
2525 }
2526
2527 workspace.focus_panel::<AgentPanel>(window, cx);
2528 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2529 panel.update(cx, |panel, cx| {
2530 panel.external_thread(None, None, Some(initial_content), window, cx);
2531 });
2532 }
2533 });
2534 })?;
2535
2536 new_window_handle.update(cx, |multi_workspace, _window, cx| {
2537 multi_workspace.activate(new_workspace.clone(), cx);
2538 })?;
2539
2540 this.update_in(cx, |this, _window, cx| {
2541 this.worktree_creation_status = None;
2542 cx.notify();
2543 })?;
2544
2545 anyhow::Ok(())
2546 }
2547}
2548
2549impl Focusable for AgentPanel {
2550 fn focus_handle(&self, cx: &App) -> FocusHandle {
2551 match &self.active_view {
2552 ActiveView::Uninitialized => self.focus_handle.clone(),
2553 ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
2554 ActiveView::History { kind } => match kind {
2555 HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
2556 HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
2557 },
2558 ActiveView::TextThread {
2559 text_thread_editor, ..
2560 } => text_thread_editor.focus_handle(cx),
2561 ActiveView::Configuration => {
2562 if let Some(configuration) = self.configuration.as_ref() {
2563 configuration.focus_handle(cx)
2564 } else {
2565 self.focus_handle.clone()
2566 }
2567 }
2568 }
2569 }
2570}
2571
2572fn agent_panel_dock_position(cx: &App) -> DockPosition {
2573 AgentSettings::get_global(cx).dock.into()
2574}
2575
2576pub enum AgentPanelEvent {
2577 ActiveViewChanged,
2578}
2579
2580impl EventEmitter<PanelEvent> for AgentPanel {}
2581impl EventEmitter<AgentPanelEvent> for AgentPanel {}
2582
2583impl Panel for AgentPanel {
2584 fn persistent_name() -> &'static str {
2585 "AgentPanel"
2586 }
2587
2588 fn panel_key() -> &'static str {
2589 AGENT_PANEL_KEY
2590 }
2591
2592 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
2593 agent_panel_dock_position(cx)
2594 }
2595
2596 fn position_is_valid(&self, position: DockPosition) -> bool {
2597 position != DockPosition::Bottom
2598 }
2599
2600 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2601 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
2602 settings
2603 .agent
2604 .get_or_insert_default()
2605 .set_dock(position.into());
2606 });
2607 }
2608
2609 fn size(&self, window: &Window, cx: &App) -> Pixels {
2610 let settings = AgentSettings::get_global(cx);
2611 match self.position(window, cx) {
2612 DockPosition::Left | DockPosition::Right => {
2613 self.width.unwrap_or(settings.default_width)
2614 }
2615 DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
2616 }
2617 }
2618
2619 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
2620 match self.position(window, cx) {
2621 DockPosition::Left | DockPosition::Right => self.width = size,
2622 DockPosition::Bottom => self.height = size,
2623 }
2624 self.serialize(cx);
2625 cx.notify();
2626 }
2627
2628 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
2629 if active
2630 && matches!(self.active_view, ActiveView::Uninitialized)
2631 && !matches!(
2632 self.worktree_creation_status,
2633 Some(WorktreeCreationStatus::Creating)
2634 )
2635 {
2636 let selected_agent = self.selected_agent.clone();
2637 self.new_agent_thread(selected_agent, window, cx);
2638 }
2639 }
2640
2641 fn remote_id() -> Option<proto::PanelId> {
2642 Some(proto::PanelId::AssistantPanel)
2643 }
2644
2645 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
2646 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
2647 }
2648
2649 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2650 Some("Agent Panel")
2651 }
2652
2653 fn toggle_action(&self) -> Box<dyn Action> {
2654 Box::new(ToggleFocus)
2655 }
2656
2657 fn activation_priority(&self) -> u32 {
2658 3
2659 }
2660
2661 fn enabled(&self, cx: &App) -> bool {
2662 AgentSettings::get_global(cx).enabled(cx)
2663 }
2664
2665 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
2666 self.zoomed
2667 }
2668
2669 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
2670 self.zoomed = zoomed;
2671 cx.notify();
2672 }
2673}
2674
2675impl AgentPanel {
2676 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
2677 const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
2678
2679 let content = match &self.active_view {
2680 ActiveView::AgentThread { server_view } => {
2681 let is_generating_title = server_view
2682 .read(cx)
2683 .as_native_thread(cx)
2684 .map_or(false, |t| t.read(cx).is_generating_title());
2685
2686 if let Some(title_editor) = server_view
2687 .read(cx)
2688 .parent_thread(cx)
2689 .map(|r| r.read(cx).title_editor.clone())
2690 {
2691 let container = div()
2692 .w_full()
2693 .on_action({
2694 let thread_view = server_view.downgrade();
2695 move |_: &menu::Confirm, window, cx| {
2696 if let Some(thread_view) = thread_view.upgrade() {
2697 thread_view.focus_handle(cx).focus(window, cx);
2698 }
2699 }
2700 })
2701 .on_action({
2702 let thread_view = server_view.downgrade();
2703 move |_: &editor::actions::Cancel, window, cx| {
2704 if let Some(thread_view) = thread_view.upgrade() {
2705 thread_view.focus_handle(cx).focus(window, cx);
2706 }
2707 }
2708 })
2709 .child(title_editor);
2710
2711 if is_generating_title {
2712 container
2713 .with_animation(
2714 "generating_title",
2715 Animation::new(Duration::from_secs(2))
2716 .repeat()
2717 .with_easing(pulsating_between(0.4, 0.8)),
2718 |div, delta| div.opacity(delta),
2719 )
2720 .into_any_element()
2721 } else {
2722 container.into_any_element()
2723 }
2724 } else {
2725 Label::new(server_view.read(cx).title(cx))
2726 .color(Color::Muted)
2727 .truncate()
2728 .into_any_element()
2729 }
2730 }
2731 ActiveView::TextThread {
2732 title_editor,
2733 text_thread_editor,
2734 ..
2735 } => {
2736 let summary = text_thread_editor.read(cx).text_thread().read(cx).summary();
2737
2738 match summary {
2739 TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT)
2740 .color(Color::Muted)
2741 .truncate()
2742 .into_any_element(),
2743 TextThreadSummary::Content(summary) => {
2744 if summary.done {
2745 div()
2746 .w_full()
2747 .child(title_editor.clone())
2748 .into_any_element()
2749 } else {
2750 Label::new(LOADING_SUMMARY_PLACEHOLDER)
2751 .truncate()
2752 .color(Color::Muted)
2753 .with_animation(
2754 "generating_title",
2755 Animation::new(Duration::from_secs(2))
2756 .repeat()
2757 .with_easing(pulsating_between(0.4, 0.8)),
2758 |label, delta| label.alpha(delta),
2759 )
2760 .into_any_element()
2761 }
2762 }
2763 TextThreadSummary::Error => h_flex()
2764 .w_full()
2765 .child(title_editor.clone())
2766 .child(
2767 IconButton::new("retry-summary-generation", IconName::RotateCcw)
2768 .icon_size(IconSize::Small)
2769 .on_click({
2770 let text_thread_editor = text_thread_editor.clone();
2771 move |_, _window, cx| {
2772 text_thread_editor.update(cx, |text_thread_editor, cx| {
2773 text_thread_editor.regenerate_summary(cx);
2774 });
2775 }
2776 })
2777 .tooltip(move |_window, cx| {
2778 cx.new(|_| {
2779 Tooltip::new("Failed to generate title")
2780 .meta("Click to try again")
2781 })
2782 .into()
2783 }),
2784 )
2785 .into_any_element(),
2786 }
2787 }
2788 ActiveView::History { kind } => {
2789 let title = match kind {
2790 HistoryKind::AgentThreads => "History",
2791 HistoryKind::TextThreads => "Text Thread History",
2792 };
2793 Label::new(title).truncate().into_any_element()
2794 }
2795 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
2796 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
2797 };
2798
2799 h_flex()
2800 .key_context("TitleEditor")
2801 .id("TitleEditor")
2802 .flex_grow()
2803 .w_full()
2804 .max_w_full()
2805 .overflow_x_scroll()
2806 .child(content)
2807 .into_any()
2808 }
2809
2810 fn handle_regenerate_thread_title(thread_view: Entity<ConnectionView>, cx: &mut App) {
2811 thread_view.update(cx, |thread_view, cx| {
2812 if let Some(thread) = thread_view.as_native_thread(cx) {
2813 thread.update(cx, |thread, cx| {
2814 thread.generate_title(cx);
2815 });
2816 }
2817 });
2818 }
2819
2820 fn handle_regenerate_text_thread_title(
2821 text_thread_editor: Entity<TextThreadEditor>,
2822 cx: &mut App,
2823 ) {
2824 text_thread_editor.update(cx, |text_thread_editor, cx| {
2825 text_thread_editor.regenerate_summary(cx);
2826 });
2827 }
2828
2829 fn render_panel_options_menu(
2830 &self,
2831 window: &mut Window,
2832 cx: &mut Context<Self>,
2833 ) -> impl IntoElement {
2834 let focus_handle = self.focus_handle(cx);
2835
2836 let full_screen_label = if self.is_zoomed(window, cx) {
2837 "Disable Full Screen"
2838 } else {
2839 "Enable Full Screen"
2840 };
2841
2842 let text_thread_view = match &self.active_view {
2843 ActiveView::TextThread {
2844 text_thread_editor, ..
2845 } => Some(text_thread_editor.clone()),
2846 _ => None,
2847 };
2848 let text_thread_with_messages = match &self.active_view {
2849 ActiveView::TextThread {
2850 text_thread_editor, ..
2851 } => text_thread_editor
2852 .read(cx)
2853 .text_thread()
2854 .read(cx)
2855 .messages(cx)
2856 .any(|message| message.role == language_model::Role::Assistant),
2857 _ => false,
2858 };
2859
2860 let thread_view = match &self.active_view {
2861 ActiveView::AgentThread { server_view } => Some(server_view.clone()),
2862 _ => None,
2863 };
2864 let thread_with_messages = match &self.active_view {
2865 ActiveView::AgentThread { server_view } => {
2866 server_view.read(cx).has_user_submitted_prompt(cx)
2867 }
2868 _ => false,
2869 };
2870 let has_auth_methods = match &self.active_view {
2871 ActiveView::AgentThread { server_view } => server_view.read(cx).has_auth_methods(),
2872 _ => false,
2873 };
2874
2875 PopoverMenu::new("agent-options-menu")
2876 .trigger_with_tooltip(
2877 IconButton::new("agent-options-menu", IconName::Ellipsis)
2878 .icon_size(IconSize::Small),
2879 {
2880 let focus_handle = focus_handle.clone();
2881 move |_window, cx| {
2882 Tooltip::for_action_in(
2883 "Toggle Agent Menu",
2884 &ToggleOptionsMenu,
2885 &focus_handle,
2886 cx,
2887 )
2888 }
2889 },
2890 )
2891 .anchor(Corner::TopRight)
2892 .with_handle(self.agent_panel_menu_handle.clone())
2893 .menu({
2894 move |window, cx| {
2895 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
2896 menu = menu.context(focus_handle.clone());
2897
2898 if thread_with_messages | text_thread_with_messages {
2899 menu = menu.header("Current Thread");
2900
2901 if let Some(text_thread_view) = text_thread_view.as_ref() {
2902 menu = menu
2903 .entry("Regenerate Thread Title", None, {
2904 let text_thread_view = text_thread_view.clone();
2905 move |_, cx| {
2906 Self::handle_regenerate_text_thread_title(
2907 text_thread_view.clone(),
2908 cx,
2909 );
2910 }
2911 })
2912 .separator();
2913 }
2914
2915 if let Some(thread_view) = thread_view.as_ref() {
2916 menu = menu
2917 .entry("Regenerate Thread Title", None, {
2918 let thread_view = thread_view.clone();
2919 move |_, cx| {
2920 Self::handle_regenerate_thread_title(
2921 thread_view.clone(),
2922 cx,
2923 );
2924 }
2925 })
2926 .separator();
2927 }
2928 }
2929
2930 menu = menu
2931 .header("MCP Servers")
2932 .action(
2933 "View Server Extensions",
2934 Box::new(zed_actions::Extensions {
2935 category_filter: Some(
2936 zed_actions::ExtensionCategoryFilter::ContextServers,
2937 ),
2938 id: None,
2939 }),
2940 )
2941 .action("Add Custom Server…", Box::new(AddContextServer))
2942 .separator()
2943 .action("Rules", Box::new(OpenRulesLibrary::default()))
2944 .action("Profiles", Box::new(ManageProfiles::default()))
2945 .action("Settings", Box::new(OpenSettings))
2946 .separator()
2947 .action(full_screen_label, Box::new(ToggleZoom));
2948
2949 if has_auth_methods {
2950 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
2951 }
2952
2953 menu
2954 }))
2955 }
2956 })
2957 }
2958
2959 fn render_recent_entries_menu(
2960 &self,
2961 icon: IconName,
2962 corner: Corner,
2963 cx: &mut Context<Self>,
2964 ) -> impl IntoElement {
2965 let focus_handle = self.focus_handle(cx);
2966
2967 PopoverMenu::new("agent-nav-menu")
2968 .trigger_with_tooltip(
2969 IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
2970 {
2971 move |_window, cx| {
2972 Tooltip::for_action_in(
2973 "Toggle Recently Updated Threads",
2974 &ToggleNavigationMenu,
2975 &focus_handle,
2976 cx,
2977 )
2978 }
2979 },
2980 )
2981 .anchor(corner)
2982 .with_handle(self.agent_navigation_menu_handle.clone())
2983 .menu({
2984 let menu = self.agent_navigation_menu.clone();
2985 move |window, cx| {
2986 telemetry::event!("View Thread History Clicked");
2987
2988 if let Some(menu) = menu.as_ref() {
2989 menu.update(cx, |_, cx| {
2990 cx.defer_in(window, |menu, window, cx| {
2991 menu.rebuild(window, cx);
2992 });
2993 })
2994 }
2995 menu.clone()
2996 }
2997 })
2998 }
2999
3000 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3001 let focus_handle = self.focus_handle(cx);
3002
3003 IconButton::new("go-back", IconName::ArrowLeft)
3004 .icon_size(IconSize::Small)
3005 .on_click(cx.listener(|this, _, window, cx| {
3006 this.go_back(&workspace::GoBack, window, cx);
3007 }))
3008 .tooltip({
3009 move |_window, cx| {
3010 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3011 }
3012 })
3013 }
3014
3015 fn project_has_git_repository(&self, cx: &App) -> bool {
3016 !self.project.read(cx).repositories(cx).is_empty()
3017 }
3018
3019 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3020 let has_git_repo = self.project_has_git_repository(cx);
3021 let is_via_collab = self.project.read(cx).is_via_collab();
3022
3023 let is_creating = matches!(
3024 self.worktree_creation_status,
3025 Some(WorktreeCreationStatus::Creating)
3026 );
3027
3028 let current_target = self.start_thread_in;
3029 let trigger_label = self.start_thread_in.label();
3030
3031 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3032 IconName::ChevronUp
3033 } else {
3034 IconName::ChevronDown
3035 };
3036
3037 let trigger_button = Button::new("thread-target-trigger", trigger_label)
3038 .label_size(LabelSize::Small)
3039 .color(Color::Muted)
3040 .icon(icon)
3041 .icon_size(IconSize::XSmall)
3042 .icon_position(IconPosition::End)
3043 .icon_color(Color::Muted)
3044 .disabled(is_creating);
3045
3046 let dock_position = AgentSettings::get_global(cx).dock;
3047 let documentation_side = match dock_position {
3048 settings::DockPosition::Left => DocumentationSide::Right,
3049 settings::DockPosition::Bottom | settings::DockPosition::Right => {
3050 DocumentationSide::Left
3051 }
3052 };
3053
3054 PopoverMenu::new("thread-target-selector")
3055 .trigger(trigger_button)
3056 .anchor(gpui::Corner::BottomRight)
3057 .with_handle(self.start_thread_in_menu_handle.clone())
3058 .menu(move |window, cx| {
3059 let current_target = current_target;
3060 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
3061 let is_local_selected = current_target == StartThreadIn::LocalProject;
3062 let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
3063
3064 let new_worktree_disabled = !has_git_repo || is_via_collab;
3065
3066 menu.header("Start Thread In…")
3067 .item(
3068 ContextMenuEntry::new("Local Project")
3069 .icon(StartThreadIn::LocalProject.icon())
3070 .icon_color(Color::Muted)
3071 .toggleable(IconPosition::End, is_local_selected)
3072 .handler(|window, cx| {
3073 window
3074 .dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
3075 }),
3076 )
3077 .item({
3078 let entry = ContextMenuEntry::new("New Worktree")
3079 .icon(StartThreadIn::NewWorktree.icon())
3080 .icon_color(Color::Muted)
3081 .toggleable(IconPosition::End, is_new_worktree_selected)
3082 .disabled(new_worktree_disabled)
3083 .handler(|window, cx| {
3084 window
3085 .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx);
3086 });
3087
3088 if new_worktree_disabled {
3089 entry.documentation_aside(documentation_side, move |_| {
3090 let reason = if !has_git_repo {
3091 "No git repository found in this project."
3092 } else {
3093 "Not available for remote/collab projects yet."
3094 };
3095 Label::new(reason)
3096 .color(Color::Muted)
3097 .size(LabelSize::Small)
3098 .into_any_element()
3099 })
3100 } else {
3101 entry
3102 }
3103 })
3104 }))
3105 })
3106 }
3107
3108 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3109 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3110 let focus_handle = self.focus_handle(cx);
3111
3112 let (selected_agent_custom_icon, selected_agent_label) =
3113 if let AgentType::Custom { name, .. } = &self.selected_agent {
3114 let store = agent_server_store.read(cx);
3115 let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
3116
3117 let label = store
3118 .agent_display_name(&ExternalAgentServerName(name.clone()))
3119 .unwrap_or_else(|| self.selected_agent.label());
3120 (icon, label)
3121 } else {
3122 (None, self.selected_agent.label())
3123 };
3124
3125 let active_thread = match &self.active_view {
3126 ActiveView::AgentThread { server_view } => server_view.read(cx).as_native_thread(cx),
3127 ActiveView::Uninitialized
3128 | ActiveView::TextThread { .. }
3129 | ActiveView::History { .. }
3130 | ActiveView::Configuration => None,
3131 };
3132
3133 let new_thread_menu = PopoverMenu::new("new_thread_menu")
3134 .trigger_with_tooltip(
3135 IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
3136 {
3137 let focus_handle = focus_handle.clone();
3138 move |_window, cx| {
3139 Tooltip::for_action_in(
3140 "New Thread…",
3141 &ToggleNewThreadMenu,
3142 &focus_handle,
3143 cx,
3144 )
3145 }
3146 },
3147 )
3148 .anchor(Corner::TopRight)
3149 .with_handle(self.new_thread_menu_handle.clone())
3150 .menu({
3151 let selected_agent = self.selected_agent.clone();
3152 let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type;
3153
3154 let workspace = self.workspace.clone();
3155 let is_via_collab = workspace
3156 .update(cx, |workspace, cx| {
3157 workspace.project().read(cx).is_via_collab()
3158 })
3159 .unwrap_or_default();
3160
3161 move |window, cx| {
3162 telemetry::event!("New Thread Clicked");
3163
3164 let active_thread = active_thread.clone();
3165 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3166 menu.context(focus_handle.clone())
3167 .when_some(active_thread, |this, active_thread| {
3168 let thread = active_thread.read(cx);
3169
3170 if !thread.is_empty() {
3171 let session_id = thread.id().clone();
3172 this.item(
3173 ContextMenuEntry::new("New From Summary")
3174 .icon(IconName::ThreadFromSummary)
3175 .icon_color(Color::Muted)
3176 .handler(move |window, cx| {
3177 window.dispatch_action(
3178 Box::new(NewNativeAgentThreadFromSummary {
3179 from_session_id: session_id.clone(),
3180 }),
3181 cx,
3182 );
3183 }),
3184 )
3185 } else {
3186 this
3187 }
3188 })
3189 .item(
3190 ContextMenuEntry::new("Zed Agent")
3191 .when(
3192 is_agent_selected(AgentType::NativeAgent)
3193 | is_agent_selected(AgentType::TextThread),
3194 |this| {
3195 this.action(Box::new(NewExternalAgentThread {
3196 agent: None,
3197 }))
3198 },
3199 )
3200 .icon(IconName::ZedAgent)
3201 .icon_color(Color::Muted)
3202 .handler({
3203 let workspace = workspace.clone();
3204 move |window, cx| {
3205 if let Some(workspace) = workspace.upgrade() {
3206 workspace.update(cx, |workspace, cx| {
3207 if let Some(panel) =
3208 workspace.panel::<AgentPanel>(cx)
3209 {
3210 panel.update(cx, |panel, cx| {
3211 panel.new_agent_thread(
3212 AgentType::NativeAgent,
3213 window,
3214 cx,
3215 );
3216 });
3217 }
3218 });
3219 }
3220 }
3221 }),
3222 )
3223 .item(
3224 ContextMenuEntry::new("Text Thread")
3225 .action(NewTextThread.boxed_clone())
3226 .icon(IconName::TextThread)
3227 .icon_color(Color::Muted)
3228 .handler({
3229 let workspace = workspace.clone();
3230 move |window, cx| {
3231 if let Some(workspace) = workspace.upgrade() {
3232 workspace.update(cx, |workspace, cx| {
3233 if let Some(panel) =
3234 workspace.panel::<AgentPanel>(cx)
3235 {
3236 panel.update(cx, |panel, cx| {
3237 panel.new_agent_thread(
3238 AgentType::TextThread,
3239 window,
3240 cx,
3241 );
3242 });
3243 }
3244 });
3245 }
3246 }
3247 }),
3248 )
3249 .separator()
3250 .header("External Agents")
3251 .map(|mut menu| {
3252 let agent_server_store = agent_server_store.read(cx);
3253 let registry_store =
3254 project::AgentRegistryStore::try_global(cx);
3255 let registry_store_ref =
3256 registry_store.as_ref().map(|s| s.read(cx));
3257
3258 struct AgentMenuItem {
3259 id: ExternalAgentServerName,
3260 display_name: SharedString,
3261 }
3262
3263 let agent_items = agent_server_store
3264 .external_agents()
3265 .map(|name| {
3266 let display_name = agent_server_store
3267 .agent_display_name(name)
3268 .or_else(|| {
3269 registry_store_ref
3270 .as_ref()
3271 .and_then(|store| store.agent(name.0.as_ref()))
3272 .map(|a| a.name().clone())
3273 })
3274 .unwrap_or_else(|| name.0.clone());
3275 AgentMenuItem {
3276 id: name.clone(),
3277 display_name,
3278 }
3279 })
3280 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3281 .collect::<Vec<_>>();
3282
3283 for item in &agent_items {
3284 let mut entry =
3285 ContextMenuEntry::new(item.display_name.clone());
3286
3287 let icon_path = agent_server_store
3288 .agent_icon(&item.id)
3289 .or_else(|| {
3290 registry_store_ref
3291 .as_ref()
3292 .and_then(|store| store.agent(item.id.0.as_str()))
3293 .and_then(|a| a.icon_path().cloned())
3294 });
3295
3296 if let Some(icon_path) = icon_path {
3297 entry = entry.custom_icon_svg(icon_path);
3298 } else {
3299 entry = entry.icon(IconName::Sparkle);
3300 }
3301
3302 entry = entry
3303 .when(
3304 is_agent_selected(AgentType::Custom {
3305 name: item.id.0.clone(),
3306 }),
3307 |this| {
3308 this.action(Box::new(
3309 NewExternalAgentThread { agent: None },
3310 ))
3311 },
3312 )
3313 .icon_color(Color::Muted)
3314 .disabled(is_via_collab)
3315 .handler({
3316 let workspace = workspace.clone();
3317 let agent_id = item.id.clone();
3318 move |window, cx| {
3319 if let Some(workspace) = workspace.upgrade() {
3320 workspace.update(cx, |workspace, cx| {
3321 if let Some(panel) =
3322 workspace.panel::<AgentPanel>(cx)
3323 {
3324 panel.update(cx, |panel, cx| {
3325 panel.new_agent_thread(
3326 AgentType::Custom {
3327 name: agent_id.0.clone(),
3328 },
3329 window,
3330 cx,
3331 );
3332 });
3333 }
3334 });
3335 }
3336 }
3337 });
3338
3339 menu = menu.item(entry);
3340 }
3341
3342 menu
3343 })
3344 .separator()
3345 .map(|mut menu| {
3346 let agent_server_store = agent_server_store.read(cx);
3347 let registry_store =
3348 project::AgentRegistryStore::try_global(cx);
3349 let registry_store_ref =
3350 registry_store.as_ref().map(|s| s.read(cx));
3351
3352 let previous_built_in_ids: &[ExternalAgentServerName] =
3353 &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()];
3354
3355 let promoted_items = previous_built_in_ids
3356 .iter()
3357 .filter(|id| {
3358 !agent_server_store.external_agents.contains_key(*id)
3359 })
3360 .filter_map(|name| {
3361 let display_name = registry_store_ref
3362 .as_ref()
3363 .and_then(|store| store.agent(name.0.as_ref()))
3364 .map(|a| a.name().clone())?;
3365 Some((name.clone(), display_name))
3366 })
3367 .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase())
3368 .collect::<Vec<_>>();
3369
3370 for (agent_id, display_name) in &promoted_items {
3371 let mut entry =
3372 ContextMenuEntry::new(display_name.clone());
3373
3374 let icon_path = registry_store_ref
3375 .as_ref()
3376 .and_then(|store| store.agent(agent_id.0.as_str()))
3377 .and_then(|a| a.icon_path().cloned());
3378
3379 if let Some(icon_path) = icon_path {
3380 entry = entry.custom_icon_svg(icon_path);
3381 } else {
3382 entry = entry.icon(IconName::Sparkle);
3383 }
3384
3385 entry = entry
3386 .icon_color(Color::Muted)
3387 .disabled(is_via_collab)
3388 .handler({
3389 let workspace = workspace.clone();
3390 let agent_id = agent_id.clone();
3391 move |window, cx| {
3392 let fs = <dyn fs::Fs>::global(cx);
3393 let agent_id_string =
3394 agent_id.to_string();
3395 settings::update_settings_file(
3396 fs,
3397 cx,
3398 move |settings, _| {
3399 let agent_servers = settings
3400 .agent_servers
3401 .get_or_insert_default();
3402 agent_servers.entry(agent_id_string).or_insert_with(|| {
3403 settings::CustomAgentServerSettings::Registry {
3404 default_mode: None,
3405 default_model: None,
3406 env: Default::default(),
3407 favorite_models: Vec::new(),
3408 default_config_options: Default::default(),
3409 favorite_config_option_values: Default::default(),
3410 }
3411 });
3412 },
3413 );
3414
3415 if let Some(workspace) = workspace.upgrade() {
3416 workspace.update(cx, |workspace, cx| {
3417 if let Some(panel) =
3418 workspace.panel::<AgentPanel>(cx)
3419 {
3420 panel.update(cx, |panel, cx| {
3421 panel.new_agent_thread(
3422 AgentType::Custom {
3423 name: agent_id.0.clone(),
3424 },
3425 window,
3426 cx,
3427 );
3428 });
3429 }
3430 });
3431 }
3432 }
3433 });
3434
3435 menu = menu.item(entry);
3436 }
3437
3438 menu
3439 })
3440 .item(
3441 ContextMenuEntry::new("Add More Agents")
3442 .icon(IconName::Plus)
3443 .icon_color(Color::Muted)
3444 .handler({
3445 move |window, cx| {
3446 window.dispatch_action(
3447 Box::new(zed_actions::AcpRegistry),
3448 cx,
3449 )
3450 }
3451 }),
3452 )
3453 }))
3454 }
3455 });
3456
3457 let is_thread_loading = self
3458 .active_thread_view()
3459 .map(|thread| thread.read(cx).is_loading())
3460 .unwrap_or(false);
3461
3462 let has_custom_icon = selected_agent_custom_icon.is_some();
3463
3464 let selected_agent = div()
3465 .id("selected_agent_icon")
3466 .when_some(selected_agent_custom_icon, |this, icon_path| {
3467 this.px_1()
3468 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
3469 })
3470 .when(!has_custom_icon, |this| {
3471 this.when_some(self.selected_agent.icon(), |this, icon| {
3472 this.px_1().child(Icon::new(icon).color(Color::Muted))
3473 })
3474 })
3475 .tooltip(move |_, cx| {
3476 Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx)
3477 });
3478
3479 let selected_agent = if is_thread_loading {
3480 selected_agent
3481 .with_animation(
3482 "pulsating-icon",
3483 Animation::new(Duration::from_secs(1))
3484 .repeat()
3485 .with_easing(pulsating_between(0.2, 0.6)),
3486 |icon, delta| icon.opacity(delta),
3487 )
3488 .into_any_element()
3489 } else {
3490 selected_agent.into_any_element()
3491 };
3492
3493 let show_history_menu = self.history_kind_for_selected_agent(cx).is_some();
3494 let has_v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
3495
3496 h_flex()
3497 .id("agent-panel-toolbar")
3498 .h(Tab::container_height(cx))
3499 .max_w_full()
3500 .flex_none()
3501 .justify_between()
3502 .gap_2()
3503 .bg(cx.theme().colors().tab_bar_background)
3504 .border_b_1()
3505 .border_color(cx.theme().colors().border)
3506 .child(
3507 h_flex()
3508 .size_full()
3509 .gap(DynamicSpacing::Base04.rems(cx))
3510 .pl(DynamicSpacing::Base04.rems(cx))
3511 .child(match &self.active_view {
3512 ActiveView::History { .. } | ActiveView::Configuration => {
3513 self.render_toolbar_back_button(cx).into_any_element()
3514 }
3515 _ => selected_agent.into_any_element(),
3516 })
3517 .child(self.render_title_view(window, cx)),
3518 )
3519 .child(
3520 h_flex()
3521 .flex_none()
3522 .gap(DynamicSpacing::Base02.rems(cx))
3523 .pl(DynamicSpacing::Base04.rems(cx))
3524 .pr(DynamicSpacing::Base06.rems(cx))
3525 .when(
3526 has_v2_flag
3527 && cx.has_flag::<AgentGitWorktreesFeatureFlag>()
3528 && !self.active_thread_has_messages(cx),
3529 |this| this.child(self.render_start_thread_in_selector(cx)),
3530 )
3531 .child(new_thread_menu)
3532 .when(show_history_menu, |this| {
3533 this.child(self.render_recent_entries_menu(
3534 IconName::MenuAltTemp,
3535 Corner::TopRight,
3536 cx,
3537 ))
3538 })
3539 .child(self.render_panel_options_menu(window, cx)),
3540 )
3541 }
3542
3543 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3544 let status = self.worktree_creation_status.as_ref()?;
3545 match status {
3546 WorktreeCreationStatus::Creating => Some(
3547 h_flex()
3548 .w_full()
3549 .px(DynamicSpacing::Base06.rems(cx))
3550 .py(DynamicSpacing::Base02.rems(cx))
3551 .gap_2()
3552 .bg(cx.theme().colors().surface_background)
3553 .border_b_1()
3554 .border_color(cx.theme().colors().border)
3555 .child(SpinnerLabel::new().size(LabelSize::Small))
3556 .child(
3557 Label::new("Creating worktree…")
3558 .color(Color::Muted)
3559 .size(LabelSize::Small),
3560 )
3561 .into_any_element(),
3562 ),
3563 WorktreeCreationStatus::Error(message) => Some(
3564 h_flex()
3565 .w_full()
3566 .px(DynamicSpacing::Base06.rems(cx))
3567 .py(DynamicSpacing::Base02.rems(cx))
3568 .gap_2()
3569 .bg(cx.theme().colors().surface_background)
3570 .border_b_1()
3571 .border_color(cx.theme().colors().border)
3572 .child(
3573 Icon::new(IconName::Warning)
3574 .size(IconSize::Small)
3575 .color(Color::Warning),
3576 )
3577 .child(
3578 Label::new(message.clone())
3579 .color(Color::Warning)
3580 .size(LabelSize::Small)
3581 .truncate(),
3582 )
3583 .into_any_element(),
3584 ),
3585 }
3586 }
3587
3588 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
3589 if TrialEndUpsell::dismissed() {
3590 return false;
3591 }
3592
3593 match &self.active_view {
3594 ActiveView::TextThread { .. } => {
3595 if LanguageModelRegistry::global(cx)
3596 .read(cx)
3597 .default_model()
3598 .is_some_and(|model| {
3599 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3600 })
3601 {
3602 return false;
3603 }
3604 }
3605 ActiveView::Uninitialized
3606 | ActiveView::AgentThread { .. }
3607 | ActiveView::History { .. }
3608 | ActiveView::Configuration => return false,
3609 }
3610
3611 let plan = self.user_store.read(cx).plan();
3612 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
3613
3614 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
3615 }
3616
3617 fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
3618 if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
3619 return false;
3620 }
3621
3622 let user_store = self.user_store.read(cx);
3623
3624 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
3625 && user_store
3626 .subscription_period()
3627 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
3628 .is_some_and(|date| date < chrono::Utc::now())
3629 {
3630 OnboardingUpsell::set_dismissed(true, cx);
3631 self.on_boarding_upsell_dismissed
3632 .store(true, Ordering::Release);
3633 return false;
3634 }
3635
3636 match &self.active_view {
3637 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3638 false
3639 }
3640 ActiveView::AgentThread { server_view, .. }
3641 if server_view.read(cx).as_native_thread(cx).is_none() =>
3642 {
3643 false
3644 }
3645 _ => {
3646 let history_is_empty = self.acp_history.read(cx).is_empty();
3647
3648 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
3649 .visible_providers()
3650 .iter()
3651 .any(|provider| {
3652 provider.is_authenticated(cx)
3653 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3654 });
3655
3656 history_is_empty || !has_configured_non_zed_providers
3657 }
3658 }
3659 }
3660
3661 fn render_onboarding(
3662 &self,
3663 _window: &mut Window,
3664 cx: &mut Context<Self>,
3665 ) -> Option<impl IntoElement> {
3666 if !self.should_render_onboarding(cx) {
3667 return None;
3668 }
3669
3670 let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. });
3671
3672 Some(
3673 div()
3674 .when(text_thread_view, |this| {
3675 this.bg(cx.theme().colors().editor_background)
3676 })
3677 .child(self.onboarding.clone()),
3678 )
3679 }
3680
3681 fn render_trial_end_upsell(
3682 &self,
3683 _window: &mut Window,
3684 cx: &mut Context<Self>,
3685 ) -> Option<impl IntoElement> {
3686 if !self.should_render_trial_end_upsell(cx) {
3687 return None;
3688 }
3689
3690 Some(
3691 v_flex()
3692 .absolute()
3693 .inset_0()
3694 .size_full()
3695 .bg(cx.theme().colors().panel_background)
3696 .opacity(0.85)
3697 .block_mouse_except_scroll()
3698 .child(EndTrialUpsell::new(Arc::new({
3699 let this = cx.entity();
3700 move |_, cx| {
3701 this.update(cx, |_this, cx| {
3702 TrialEndUpsell::set_dismissed(true, cx);
3703 cx.notify();
3704 });
3705 }
3706 }))),
3707 )
3708 }
3709
3710 fn emit_configuration_error_telemetry_if_needed(
3711 &mut self,
3712 configuration_error: Option<&ConfigurationError>,
3713 ) {
3714 let error_kind = configuration_error.map(|err| match err {
3715 ConfigurationError::NoProvider => "no_provider",
3716 ConfigurationError::ModelNotFound => "model_not_found",
3717 ConfigurationError::ProviderNotAuthenticated(_) => "provider_not_authenticated",
3718 });
3719
3720 let error_kind_string = error_kind.map(String::from);
3721
3722 if self.last_configuration_error_telemetry == error_kind_string {
3723 return;
3724 }
3725
3726 self.last_configuration_error_telemetry = error_kind_string;
3727
3728 if let Some(kind) = error_kind {
3729 let message = configuration_error
3730 .map(|err| err.to_string())
3731 .unwrap_or_default();
3732
3733 telemetry::event!("Agent Panel Error Shown", kind = kind, message = message,);
3734 }
3735 }
3736
3737 fn render_configuration_error(
3738 &self,
3739 border_bottom: bool,
3740 configuration_error: &ConfigurationError,
3741 focus_handle: &FocusHandle,
3742 cx: &mut App,
3743 ) -> impl IntoElement {
3744 let zed_provider_configured = AgentSettings::get_global(cx)
3745 .default_model
3746 .as_ref()
3747 .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev");
3748
3749 let callout = if zed_provider_configured {
3750 Callout::new()
3751 .icon(IconName::Warning)
3752 .severity(Severity::Warning)
3753 .when(border_bottom, |this| {
3754 this.border_position(ui::BorderPosition::Bottom)
3755 })
3756 .title("Sign in to continue using Zed as your LLM provider.")
3757 .actions_slot(
3758 Button::new("sign_in", "Sign In")
3759 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
3760 .label_size(LabelSize::Small)
3761 .on_click({
3762 let workspace = self.workspace.clone();
3763 move |_, _, cx| {
3764 let Ok(client) =
3765 workspace.update(cx, |workspace, _| workspace.client().clone())
3766 else {
3767 return;
3768 };
3769
3770 cx.spawn(async move |cx| {
3771 client.sign_in_with_optional_connect(true, cx).await
3772 })
3773 .detach_and_log_err(cx);
3774 }
3775 }),
3776 )
3777 } else {
3778 Callout::new()
3779 .icon(IconName::Warning)
3780 .severity(Severity::Warning)
3781 .when(border_bottom, |this| {
3782 this.border_position(ui::BorderPosition::Bottom)
3783 })
3784 .title(configuration_error.to_string())
3785 .actions_slot(
3786 Button::new("settings", "Configure")
3787 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
3788 .label_size(LabelSize::Small)
3789 .key_binding(
3790 KeyBinding::for_action_in(&OpenSettings, focus_handle, cx)
3791 .map(|kb| kb.size(rems_from_px(12.))),
3792 )
3793 .on_click(|_event, window, cx| {
3794 window.dispatch_action(OpenSettings.boxed_clone(), cx)
3795 }),
3796 )
3797 };
3798
3799 match configuration_error {
3800 ConfigurationError::ModelNotFound
3801 | ConfigurationError::ProviderNotAuthenticated(_)
3802 | ConfigurationError::NoProvider => callout.into_any_element(),
3803 }
3804 }
3805
3806 fn render_text_thread(
3807 &self,
3808 text_thread_editor: &Entity<TextThreadEditor>,
3809 buffer_search_bar: &Entity<BufferSearchBar>,
3810 window: &mut Window,
3811 cx: &mut Context<Self>,
3812 ) -> Div {
3813 let mut registrar = buffer_search::DivRegistrar::new(
3814 |this, _, _cx| match &this.active_view {
3815 ActiveView::TextThread {
3816 buffer_search_bar, ..
3817 } => Some(buffer_search_bar.clone()),
3818 _ => None,
3819 },
3820 cx,
3821 );
3822 BufferSearchBar::register(&mut registrar);
3823 registrar
3824 .into_div()
3825 .size_full()
3826 .relative()
3827 .map(|parent| {
3828 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
3829 if buffer_search_bar.is_dismissed() {
3830 return parent;
3831 }
3832 parent.child(
3833 div()
3834 .p(DynamicSpacing::Base08.rems(cx))
3835 .border_b_1()
3836 .border_color(cx.theme().colors().border_variant)
3837 .bg(cx.theme().colors().editor_background)
3838 .child(buffer_search_bar.render(window, cx)),
3839 )
3840 })
3841 })
3842 .child(text_thread_editor.clone())
3843 .child(self.render_drag_target(cx))
3844 }
3845
3846 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
3847 let is_local = self.project.read(cx).is_local();
3848 div()
3849 .invisible()
3850 .absolute()
3851 .top_0()
3852 .right_0()
3853 .bottom_0()
3854 .left_0()
3855 .bg(cx.theme().colors().drop_target_background)
3856 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
3857 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
3858 .when(is_local, |this| {
3859 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
3860 })
3861 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
3862 let item = tab.pane.read(cx).item_for_index(tab.ix);
3863 let project_paths = item
3864 .and_then(|item| item.project_path(cx))
3865 .into_iter()
3866 .collect::<Vec<_>>();
3867 this.handle_drop(project_paths, vec![], window, cx);
3868 }))
3869 .on_drop(
3870 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
3871 let project_paths = selection
3872 .items()
3873 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
3874 .collect::<Vec<_>>();
3875 this.handle_drop(project_paths, vec![], window, cx);
3876 }),
3877 )
3878 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
3879 let tasks = paths
3880 .paths()
3881 .iter()
3882 .map(|path| {
3883 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
3884 })
3885 .collect::<Vec<_>>();
3886 cx.spawn_in(window, async move |this, cx| {
3887 let mut paths = vec![];
3888 let mut added_worktrees = vec![];
3889 let opened_paths = futures::future::join_all(tasks).await;
3890 for entry in opened_paths {
3891 if let Some((worktree, project_path)) = entry.log_err() {
3892 added_worktrees.push(worktree);
3893 paths.push(project_path);
3894 }
3895 }
3896 this.update_in(cx, |this, window, cx| {
3897 this.handle_drop(paths, added_worktrees, window, cx);
3898 })
3899 .ok();
3900 })
3901 .detach();
3902 }))
3903 }
3904
3905 fn handle_drop(
3906 &mut self,
3907 paths: Vec<ProjectPath>,
3908 added_worktrees: Vec<Entity<Worktree>>,
3909 window: &mut Window,
3910 cx: &mut Context<Self>,
3911 ) {
3912 match &self.active_view {
3913 ActiveView::AgentThread { server_view } => {
3914 server_view.update(cx, |thread_view, cx| {
3915 thread_view.insert_dragged_files(paths, added_worktrees, window, cx);
3916 });
3917 }
3918 ActiveView::TextThread {
3919 text_thread_editor, ..
3920 } => {
3921 text_thread_editor.update(cx, |text_thread_editor, cx| {
3922 TextThreadEditor::insert_dragged_files(
3923 text_thread_editor,
3924 paths,
3925 added_worktrees,
3926 window,
3927 cx,
3928 );
3929 });
3930 }
3931 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3932 }
3933 }
3934
3935 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
3936 if !self.show_trust_workspace_message {
3937 return None;
3938 }
3939
3940 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
3941
3942 Some(
3943 Callout::new()
3944 .icon(IconName::Warning)
3945 .severity(Severity::Warning)
3946 .border_position(ui::BorderPosition::Bottom)
3947 .title("You're in Restricted Mode")
3948 .description(description)
3949 .actions_slot(
3950 Button::new("open-trust-modal", "Configure Project Trust")
3951 .label_size(LabelSize::Small)
3952 .style(ButtonStyle::Outlined)
3953 .on_click({
3954 cx.listener(move |this, _, window, cx| {
3955 this.workspace
3956 .update(cx, |workspace, cx| {
3957 workspace
3958 .show_worktree_trust_security_modal(true, window, cx)
3959 })
3960 .log_err();
3961 })
3962 }),
3963 ),
3964 )
3965 }
3966
3967 fn key_context(&self) -> KeyContext {
3968 let mut key_context = KeyContext::new_with_defaults();
3969 key_context.add("AgentPanel");
3970 match &self.active_view {
3971 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
3972 ActiveView::TextThread { .. } => key_context.add("text_thread"),
3973 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3974 }
3975 key_context
3976 }
3977}
3978
3979impl Render for AgentPanel {
3980 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3981 // WARNING: Changes to this element hierarchy can have
3982 // non-obvious implications to the layout of children.
3983 //
3984 // If you need to change it, please confirm:
3985 // - The message editor expands (cmd-option-esc) correctly
3986 // - When expanded, the buttons at the bottom of the panel are displayed correctly
3987 // - Font size works as expected and can be changed with cmd-+/cmd-
3988 // - Scrolling in all views works as expected
3989 // - Files can be dropped into the panel
3990 let content = v_flex()
3991 .relative()
3992 .size_full()
3993 .justify_between()
3994 .key_context(self.key_context())
3995 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
3996 this.new_thread(action, window, cx);
3997 }))
3998 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
3999 this.open_history(window, cx);
4000 }))
4001 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4002 this.open_configuration(window, cx);
4003 }))
4004 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4005 .on_action(cx.listener(Self::deploy_rules_library))
4006 .on_action(cx.listener(Self::go_back))
4007 .on_action(cx.listener(Self::toggle_navigation_menu))
4008 .on_action(cx.listener(Self::toggle_options_menu))
4009 .on_action(cx.listener(Self::increase_font_size))
4010 .on_action(cx.listener(Self::decrease_font_size))
4011 .on_action(cx.listener(Self::reset_font_size))
4012 .on_action(cx.listener(Self::toggle_zoom))
4013 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4014 if let Some(thread_view) = this.active_thread_view() {
4015 thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx))
4016 }
4017 }))
4018 .child(self.render_toolbar(window, cx))
4019 .children(self.render_worktree_creation_status(cx))
4020 .children(self.render_workspace_trust_message(cx))
4021 .children(self.render_onboarding(window, cx))
4022 .map(|parent| {
4023 // Emit configuration error telemetry before entering the match to avoid borrow conflicts
4024 if matches!(&self.active_view, ActiveView::TextThread { .. }) {
4025 let model_registry = LanguageModelRegistry::read_global(cx);
4026 let configuration_error =
4027 model_registry.configuration_error(model_registry.default_model(), cx);
4028 self.emit_configuration_error_telemetry_if_needed(configuration_error.as_ref());
4029 }
4030
4031 match &self.active_view {
4032 ActiveView::Uninitialized => parent,
4033 ActiveView::AgentThread { server_view, .. } => parent
4034 .child(server_view.clone())
4035 .child(self.render_drag_target(cx)),
4036 ActiveView::History { kind } => match kind {
4037 HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
4038 HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
4039 },
4040 ActiveView::TextThread {
4041 text_thread_editor,
4042 buffer_search_bar,
4043 ..
4044 } => {
4045 let model_registry = LanguageModelRegistry::read_global(cx);
4046 let configuration_error =
4047 model_registry.configuration_error(model_registry.default_model(), cx);
4048
4049 parent
4050 .map(|this| {
4051 if !self.should_render_onboarding(cx)
4052 && let Some(err) = configuration_error.as_ref()
4053 {
4054 this.child(self.render_configuration_error(
4055 true,
4056 err,
4057 &self.focus_handle(cx),
4058 cx,
4059 ))
4060 } else {
4061 this
4062 }
4063 })
4064 .child(self.render_text_thread(
4065 text_thread_editor,
4066 buffer_search_bar,
4067 window,
4068 cx,
4069 ))
4070 }
4071 ActiveView::Configuration => parent.children(self.configuration.clone()),
4072 }
4073 })
4074 .children(self.render_trial_end_upsell(window, cx));
4075
4076 match self.active_view.which_font_size_used() {
4077 WhichFontSize::AgentFont => {
4078 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4079 .size_full()
4080 .child(content)
4081 .into_any()
4082 }
4083 _ => content.into_any(),
4084 }
4085 }
4086}
4087
4088struct PromptLibraryInlineAssist {
4089 workspace: WeakEntity<Workspace>,
4090}
4091
4092impl PromptLibraryInlineAssist {
4093 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4094 Self { workspace }
4095 }
4096}
4097
4098impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4099 fn assist(
4100 &self,
4101 prompt_editor: &Entity<Editor>,
4102 initial_prompt: Option<String>,
4103 window: &mut Window,
4104 cx: &mut Context<RulesLibrary>,
4105 ) {
4106 InlineAssistant::update_global(cx, |assistant, cx| {
4107 let Some(workspace) = self.workspace.upgrade() else {
4108 return;
4109 };
4110 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4111 return;
4112 };
4113 let project = workspace.read(cx).project().downgrade();
4114 let panel = panel.read(cx);
4115 let thread_store = panel.thread_store().clone();
4116 let history = panel.history().downgrade();
4117 assistant.assist(
4118 prompt_editor,
4119 self.workspace.clone(),
4120 project,
4121 thread_store,
4122 None,
4123 history,
4124 initial_prompt,
4125 window,
4126 cx,
4127 );
4128 })
4129 }
4130
4131 fn focus_agent_panel(
4132 &self,
4133 workspace: &mut Workspace,
4134 window: &mut Window,
4135 cx: &mut Context<Workspace>,
4136 ) -> bool {
4137 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4138 }
4139}
4140
4141pub struct ConcreteAssistantPanelDelegate;
4142
4143impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
4144 fn active_text_thread_editor(
4145 &self,
4146 workspace: &mut Workspace,
4147 _window: &mut Window,
4148 cx: &mut Context<Workspace>,
4149 ) -> Option<Entity<TextThreadEditor>> {
4150 let panel = workspace.panel::<AgentPanel>(cx)?;
4151 panel.read(cx).active_text_thread_editor()
4152 }
4153
4154 fn open_local_text_thread(
4155 &self,
4156 workspace: &mut Workspace,
4157 path: Arc<Path>,
4158 window: &mut Window,
4159 cx: &mut Context<Workspace>,
4160 ) -> Task<Result<()>> {
4161 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4162 return Task::ready(Err(anyhow!("Agent panel not found")));
4163 };
4164
4165 panel.update(cx, |panel, cx| {
4166 panel.open_saved_text_thread(path, window, cx)
4167 })
4168 }
4169
4170 fn open_remote_text_thread(
4171 &self,
4172 _workspace: &mut Workspace,
4173 _text_thread_id: assistant_text_thread::TextThreadId,
4174 _window: &mut Window,
4175 _cx: &mut Context<Workspace>,
4176 ) -> Task<Result<Entity<TextThreadEditor>>> {
4177 Task::ready(Err(anyhow!("opening remote context not implemented")))
4178 }
4179
4180 fn quote_selection(
4181 &self,
4182 workspace: &mut Workspace,
4183 selection_ranges: Vec<Range<Anchor>>,
4184 buffer: Entity<MultiBuffer>,
4185 window: &mut Window,
4186 cx: &mut Context<Workspace>,
4187 ) {
4188 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4189 return;
4190 };
4191
4192 if !panel.focus_handle(cx).contains_focused(window, cx) {
4193 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
4194 }
4195
4196 panel.update(cx, |_, cx| {
4197 // Wait to create a new context until the workspace is no longer
4198 // being updated.
4199 cx.defer_in(window, move |panel, window, cx| {
4200 if let Some(thread_view) = panel.active_thread_view() {
4201 thread_view.update(cx, |thread_view, cx| {
4202 thread_view.insert_selections(window, cx);
4203 });
4204 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
4205 let snapshot = buffer.read(cx).snapshot(cx);
4206 let selection_ranges = selection_ranges
4207 .into_iter()
4208 .map(|range| range.to_point(&snapshot))
4209 .collect::<Vec<_>>();
4210
4211 text_thread_editor.update(cx, |text_thread_editor, cx| {
4212 text_thread_editor.quote_ranges(selection_ranges, snapshot, window, cx)
4213 });
4214 }
4215 });
4216 });
4217 }
4218
4219 fn quote_terminal_text(
4220 &self,
4221 workspace: &mut Workspace,
4222 text: String,
4223 window: &mut Window,
4224 cx: &mut Context<Workspace>,
4225 ) {
4226 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4227 return;
4228 };
4229
4230 if !panel.focus_handle(cx).contains_focused(window, cx) {
4231 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
4232 }
4233
4234 panel.update(cx, |_, cx| {
4235 // Wait to create a new context until the workspace is no longer
4236 // being updated.
4237 cx.defer_in(window, move |panel, window, cx| {
4238 if let Some(thread_view) = panel.active_thread_view() {
4239 thread_view.update(cx, |thread_view, cx| {
4240 thread_view.insert_terminal_text(text, window, cx);
4241 });
4242 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
4243 text_thread_editor.update(cx, |text_thread_editor, cx| {
4244 text_thread_editor.quote_terminal_text(text, window, cx)
4245 });
4246 }
4247 });
4248 });
4249 }
4250}
4251
4252struct OnboardingUpsell;
4253
4254impl Dismissable for OnboardingUpsell {
4255 const KEY: &'static str = "dismissed-trial-upsell";
4256}
4257
4258struct TrialEndUpsell;
4259
4260impl Dismissable for TrialEndUpsell {
4261 const KEY: &'static str = "dismissed-trial-end-upsell";
4262}
4263
4264/// Test-only helper methods
4265#[cfg(any(test, feature = "test-support"))]
4266impl AgentPanel {
4267 /// Opens an external thread using an arbitrary AgentServer.
4268 ///
4269 /// This is a test-only helper that allows visual tests and integration tests
4270 /// to inject a stub server without modifying production code paths.
4271 /// Not compiled into production builds.
4272 pub fn open_external_thread_with_server(
4273 &mut self,
4274 server: Rc<dyn AgentServer>,
4275 window: &mut Window,
4276 cx: &mut Context<Self>,
4277 ) {
4278 let workspace = self.workspace.clone();
4279 let project = self.project.clone();
4280
4281 let ext_agent = ExternalAgent::Custom {
4282 name: server.name(),
4283 };
4284
4285 self.create_external_thread(
4286 server, None, None, workspace, project, ext_agent, window, cx,
4287 );
4288 }
4289
4290 /// Returns the currently active thread view, if any.
4291 ///
4292 /// This is a test-only accessor that exposes the private `active_thread_view()`
4293 /// method for test assertions. Not compiled into production builds.
4294 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConnectionView>> {
4295 self.active_thread_view()
4296 }
4297
4298 /// Sets the start_thread_in value directly, bypassing validation.
4299 ///
4300 /// This is a test-only helper for visual tests that need to show specific
4301 /// start_thread_in states without requiring a real git repository.
4302 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4303 self.start_thread_in = target;
4304 cx.notify();
4305 }
4306
4307 /// Returns the current worktree creation status.
4308 ///
4309 /// This is a test-only helper for visual tests.
4310 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4311 self.worktree_creation_status.as_ref()
4312 }
4313
4314 /// Sets the worktree creation status directly.
4315 ///
4316 /// This is a test-only helper for visual tests that need to show the
4317 /// "Creating worktree…" spinner or error banners.
4318 pub fn set_worktree_creation_status_for_tests(
4319 &mut self,
4320 status: Option<WorktreeCreationStatus>,
4321 cx: &mut Context<Self>,
4322 ) {
4323 self.worktree_creation_status = status;
4324 cx.notify();
4325 }
4326
4327 /// Opens the history view.
4328 ///
4329 /// This is a test-only helper that exposes the private `open_history()`
4330 /// method for visual tests.
4331 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4332 self.open_history(window, cx);
4333 }
4334
4335 /// Opens the start_thread_in selector popover menu.
4336 ///
4337 /// This is a test-only helper for visual tests.
4338 pub fn open_start_thread_in_menu_for_tests(
4339 &mut self,
4340 window: &mut Window,
4341 cx: &mut Context<Self>,
4342 ) {
4343 self.start_thread_in_menu_handle.show(window, cx);
4344 }
4345
4346 /// Dismisses the start_thread_in dropdown menu.
4347 ///
4348 /// This is a test-only helper for visual tests.
4349 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4350 self.start_thread_in_menu_handle.hide(cx);
4351 }
4352}
4353
4354#[cfg(test)]
4355mod tests {
4356 use super::*;
4357 use crate::connection_view::tests::{StubAgentServer, init_test};
4358 use assistant_text_thread::TextThreadStore;
4359 use feature_flags::FeatureFlagAppExt;
4360 use fs::FakeFs;
4361 use gpui::{TestAppContext, VisualTestContext};
4362 use project::Project;
4363 use serde_json::json;
4364 use workspace::MultiWorkspace;
4365
4366 #[gpui::test]
4367 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4368 init_test(cx);
4369 cx.update(|cx| {
4370 cx.update_flags(true, vec!["agent-v2".to_string()]);
4371 agent::ThreadStore::init_global(cx);
4372 language_model::LanguageModelRegistry::test(cx);
4373 });
4374
4375 // --- Create a MultiWorkspace window with two workspaces ---
4376 let fs = FakeFs::new(cx.executor());
4377 let project_a = Project::test(fs.clone(), [], cx).await;
4378 let project_b = Project::test(fs, [], cx).await;
4379
4380 let multi_workspace =
4381 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4382
4383 let workspace_a = multi_workspace
4384 .read_with(cx, |multi_workspace, _cx| {
4385 multi_workspace.workspace().clone()
4386 })
4387 .unwrap();
4388
4389 let workspace_b = multi_workspace
4390 .update(cx, |multi_workspace, window, cx| {
4391 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4392 })
4393 .unwrap();
4394
4395 workspace_a.update(cx, |workspace, _cx| {
4396 workspace.set_random_database_id();
4397 });
4398 workspace_b.update(cx, |workspace, _cx| {
4399 workspace.set_random_database_id();
4400 });
4401
4402 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4403
4404 // --- Set up workspace A: width=300, with an active thread ---
4405 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4406 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx));
4407 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
4408 });
4409
4410 panel_a.update(cx, |panel, _cx| {
4411 panel.width = Some(px(300.0));
4412 });
4413
4414 panel_a.update_in(cx, |panel, window, cx| {
4415 panel.open_external_thread_with_server(
4416 Rc::new(StubAgentServer::default_response()),
4417 window,
4418 cx,
4419 );
4420 });
4421
4422 cx.run_until_parked();
4423
4424 panel_a.read_with(cx, |panel, cx| {
4425 assert!(
4426 panel.active_agent_thread(cx).is_some(),
4427 "workspace A should have an active thread after connection"
4428 );
4429 });
4430
4431 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4432
4433 // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
4434 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4435 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx));
4436 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
4437 });
4438
4439 panel_b.update(cx, |panel, _cx| {
4440 panel.width = Some(px(400.0));
4441 panel.selected_agent = AgentType::Custom {
4442 name: "claude-acp".into(),
4443 };
4444 });
4445
4446 // --- Serialize both panels ---
4447 panel_a.update(cx, |panel, cx| panel.serialize(cx));
4448 panel_b.update(cx, |panel, cx| panel.serialize(cx));
4449 cx.run_until_parked();
4450
4451 // --- Load fresh panels for each workspace and verify independent state ---
4452 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
4453
4454 let async_cx = cx.update(|window, cx| window.to_async(cx));
4455 let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx)
4456 .await
4457 .expect("panel A load should succeed");
4458 cx.run_until_parked();
4459
4460 let async_cx = cx.update(|window, cx| window.to_async(cx));
4461 let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx)
4462 .await
4463 .expect("panel B load should succeed");
4464 cx.run_until_parked();
4465
4466 // Workspace A should restore its thread, width, and agent type
4467 loaded_a.read_with(cx, |panel, _cx| {
4468 assert_eq!(
4469 panel.width,
4470 Some(px(300.0)),
4471 "workspace A width should be restored"
4472 );
4473 assert_eq!(
4474 panel.selected_agent, agent_type_a,
4475 "workspace A agent type should be restored"
4476 );
4477 assert!(
4478 panel.active_thread_view().is_some(),
4479 "workspace A should have its active thread restored"
4480 );
4481 });
4482
4483 // Workspace B should restore its own width and agent type, with no thread
4484 loaded_b.read_with(cx, |panel, _cx| {
4485 assert_eq!(
4486 panel.width,
4487 Some(px(400.0)),
4488 "workspace B width should be restored"
4489 );
4490 assert_eq!(
4491 panel.selected_agent,
4492 AgentType::Custom {
4493 name: "claude-acp".into()
4494 },
4495 "workspace B agent type should be restored"
4496 );
4497 assert!(
4498 panel.active_thread_view().is_none(),
4499 "workspace B should have no active thread"
4500 );
4501 });
4502 }
4503
4504 // Simple regression test
4505 #[gpui::test]
4506 async fn test_new_text_thread_action_handler(cx: &mut TestAppContext) {
4507 init_test(cx);
4508
4509 let fs = FakeFs::new(cx.executor());
4510
4511 cx.update(|cx| {
4512 cx.update_flags(true, vec!["agent-v2".to_string()]);
4513 agent::ThreadStore::init_global(cx);
4514 language_model::LanguageModelRegistry::test(cx);
4515 let slash_command_registry =
4516 assistant_slash_command::SlashCommandRegistry::default_global(cx);
4517 slash_command_registry
4518 .register_command(assistant_slash_commands::DefaultSlashCommand, false);
4519 <dyn fs::Fs>::set_global(fs.clone(), cx);
4520 });
4521
4522 let project = Project::test(fs.clone(), [], cx).await;
4523
4524 let multi_workspace =
4525 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4526
4527 let workspace_a = multi_workspace
4528 .read_with(cx, |multi_workspace, _cx| {
4529 multi_workspace.workspace().clone()
4530 })
4531 .unwrap();
4532
4533 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4534
4535 workspace_a.update_in(cx, |workspace, window, cx| {
4536 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4537 let panel =
4538 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
4539 workspace.add_panel(panel, window, cx);
4540 });
4541
4542 cx.run_until_parked();
4543
4544 workspace_a.update_in(cx, |_, window, cx| {
4545 window.dispatch_action(NewTextThread.boxed_clone(), cx);
4546 });
4547
4548 cx.run_until_parked();
4549 }
4550
4551 #[gpui::test]
4552 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
4553 init_test(cx);
4554 cx.update(|cx| {
4555 cx.update_flags(true, vec!["agent-v2".to_string()]);
4556 agent::ThreadStore::init_global(cx);
4557 language_model::LanguageModelRegistry::test(cx);
4558 });
4559
4560 let fs = FakeFs::new(cx.executor());
4561 fs.insert_tree(
4562 "/project",
4563 json!({
4564 ".git": {},
4565 "src": {
4566 "main.rs": "fn main() {}"
4567 }
4568 }),
4569 )
4570 .await;
4571 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
4572
4573 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4574
4575 let multi_workspace =
4576 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4577
4578 let workspace = multi_workspace
4579 .read_with(cx, |multi_workspace, _cx| {
4580 multi_workspace.workspace().clone()
4581 })
4582 .unwrap();
4583
4584 workspace.update(cx, |workspace, _cx| {
4585 workspace.set_random_database_id();
4586 });
4587
4588 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4589
4590 // Wait for the project to discover the git repository.
4591 cx.run_until_parked();
4592
4593 let panel = workspace.update_in(cx, |workspace, window, cx| {
4594 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4595 let panel =
4596 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
4597 workspace.add_panel(panel.clone(), window, cx);
4598 panel
4599 });
4600
4601 cx.run_until_parked();
4602
4603 // Default thread target should be LocalProject.
4604 panel.read_with(cx, |panel, _cx| {
4605 assert_eq!(
4606 *panel.start_thread_in(),
4607 StartThreadIn::LocalProject,
4608 "default thread target should be LocalProject"
4609 );
4610 });
4611
4612 // Start a new thread with the default LocalProject target.
4613 // Use StubAgentServer so the thread connects immediately in tests.
4614 panel.update_in(cx, |panel, window, cx| {
4615 panel.open_external_thread_with_server(
4616 Rc::new(StubAgentServer::default_response()),
4617 window,
4618 cx,
4619 );
4620 });
4621
4622 cx.run_until_parked();
4623
4624 // MultiWorkspace should still have exactly one workspace (no worktree created).
4625 multi_workspace
4626 .read_with(cx, |multi_workspace, _cx| {
4627 assert_eq!(
4628 multi_workspace.workspaces().len(),
4629 1,
4630 "LocalProject should not create a new workspace"
4631 );
4632 })
4633 .unwrap();
4634
4635 // The thread should be active in the panel.
4636 panel.read_with(cx, |panel, cx| {
4637 assert!(
4638 panel.active_agent_thread(cx).is_some(),
4639 "a thread should be running in the current workspace"
4640 );
4641 });
4642
4643 // The thread target should still be LocalProject (unchanged).
4644 panel.read_with(cx, |panel, _cx| {
4645 assert_eq!(
4646 *panel.start_thread_in(),
4647 StartThreadIn::LocalProject,
4648 "thread target should remain LocalProject"
4649 );
4650 });
4651
4652 // No worktree creation status should be set.
4653 panel.read_with(cx, |panel, _cx| {
4654 assert!(
4655 panel.worktree_creation_status.is_none(),
4656 "no worktree creation should have occurred"
4657 );
4658 });
4659 }
4660
4661 #[gpui::test]
4662 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
4663 init_test(cx);
4664 cx.update(|cx| {
4665 cx.update_flags(
4666 true,
4667 vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()],
4668 );
4669 agent::ThreadStore::init_global(cx);
4670 language_model::LanguageModelRegistry::test(cx);
4671 });
4672
4673 let fs = FakeFs::new(cx.executor());
4674 fs.insert_tree(
4675 "/project",
4676 json!({
4677 ".git": {},
4678 "src": {
4679 "main.rs": "fn main() {}"
4680 }
4681 }),
4682 )
4683 .await;
4684 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
4685
4686 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4687
4688 let multi_workspace =
4689 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4690
4691 let workspace = multi_workspace
4692 .read_with(cx, |multi_workspace, _cx| {
4693 multi_workspace.workspace().clone()
4694 })
4695 .unwrap();
4696
4697 workspace.update(cx, |workspace, _cx| {
4698 workspace.set_random_database_id();
4699 });
4700
4701 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4702
4703 // Wait for the project to discover the git repository.
4704 cx.run_until_parked();
4705
4706 let panel = workspace.update_in(cx, |workspace, window, cx| {
4707 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4708 let panel =
4709 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
4710 workspace.add_panel(panel.clone(), window, cx);
4711 panel
4712 });
4713
4714 cx.run_until_parked();
4715
4716 // Default should be LocalProject.
4717 panel.read_with(cx, |panel, _cx| {
4718 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
4719 });
4720
4721 // Change thread target to NewWorktree.
4722 panel.update(cx, |panel, cx| {
4723 panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx);
4724 });
4725
4726 panel.read_with(cx, |panel, _cx| {
4727 assert_eq!(
4728 *panel.start_thread_in(),
4729 StartThreadIn::NewWorktree,
4730 "thread target should be NewWorktree after set_thread_target"
4731 );
4732 });
4733
4734 // Let serialization complete.
4735 cx.run_until_parked();
4736
4737 // Load a fresh panel from the serialized data.
4738 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
4739 let async_cx = cx.update(|window, cx| window.to_async(cx));
4740 let loaded_panel =
4741 AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx)
4742 .await
4743 .expect("panel load should succeed");
4744 cx.run_until_parked();
4745
4746 loaded_panel.read_with(cx, |panel, _cx| {
4747 assert_eq!(
4748 *panel.start_thread_in(),
4749 StartThreadIn::NewWorktree,
4750 "thread target should survive serialization round-trip"
4751 );
4752 });
4753 }
4754
4755 #[gpui::test]
4756 async fn test_thread_target_deserialization_falls_back_when_worktree_flag_disabled(
4757 cx: &mut TestAppContext,
4758 ) {
4759 init_test(cx);
4760 cx.update(|cx| {
4761 cx.update_flags(
4762 true,
4763 vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()],
4764 );
4765 agent::ThreadStore::init_global(cx);
4766 language_model::LanguageModelRegistry::test(cx);
4767 });
4768
4769 let fs = FakeFs::new(cx.executor());
4770 fs.insert_tree(
4771 "/project",
4772 json!({
4773 ".git": {},
4774 "src": {
4775 "main.rs": "fn main() {}"
4776 }
4777 }),
4778 )
4779 .await;
4780 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
4781
4782 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4783
4784 let multi_workspace =
4785 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4786
4787 let workspace = multi_workspace
4788 .read_with(cx, |multi_workspace, _cx| {
4789 multi_workspace.workspace().clone()
4790 })
4791 .unwrap();
4792
4793 workspace.update(cx, |workspace, _cx| {
4794 workspace.set_random_database_id();
4795 });
4796
4797 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4798
4799 // Wait for the project to discover the git repository.
4800 cx.run_until_parked();
4801
4802 let panel = workspace.update_in(cx, |workspace, window, cx| {
4803 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4804 let panel =
4805 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
4806 workspace.add_panel(panel.clone(), window, cx);
4807 panel
4808 });
4809
4810 cx.run_until_parked();
4811
4812 panel.update(cx, |panel, cx| {
4813 panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx);
4814 });
4815
4816 panel.read_with(cx, |panel, _cx| {
4817 assert_eq!(
4818 *panel.start_thread_in(),
4819 StartThreadIn::NewWorktree,
4820 "thread target should be NewWorktree before reload"
4821 );
4822 });
4823
4824 // Let serialization complete.
4825 cx.run_until_parked();
4826
4827 // Disable worktree flag and reload panel from serialized data.
4828 cx.update(|_, cx| {
4829 cx.update_flags(true, vec!["agent-v2".to_string()]);
4830 });
4831
4832 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
4833 let async_cx = cx.update(|window, cx| window.to_async(cx));
4834 let loaded_panel =
4835 AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx)
4836 .await
4837 .expect("panel load should succeed");
4838 cx.run_until_parked();
4839
4840 loaded_panel.read_with(cx, |panel, _cx| {
4841 assert_eq!(
4842 *panel.start_thread_in(),
4843 StartThreadIn::LocalProject,
4844 "thread target should fall back to LocalProject when worktree flag is disabled"
4845 );
4846 });
4847 }
4848
4849 #[gpui::test]
4850 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
4851 init_test(cx);
4852
4853 let fs = FakeFs::new(cx.executor());
4854 cx.update(|cx| {
4855 cx.update_flags(true, vec!["agent-v2".to_string()]);
4856 agent::ThreadStore::init_global(cx);
4857 language_model::LanguageModelRegistry::test(cx);
4858 <dyn fs::Fs>::set_global(fs.clone(), cx);
4859 });
4860
4861 fs.insert_tree(
4862 "/project",
4863 json!({
4864 ".git": {},
4865 "src": {
4866 "main.rs": "fn main() {}"
4867 }
4868 }),
4869 )
4870 .await;
4871
4872 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4873
4874 let multi_workspace =
4875 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4876
4877 let workspace = multi_workspace
4878 .read_with(cx, |multi_workspace, _cx| {
4879 multi_workspace.workspace().clone()
4880 })
4881 .unwrap();
4882
4883 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4884
4885 let panel = workspace.update_in(cx, |workspace, window, cx| {
4886 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4887 let panel =
4888 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
4889 workspace.add_panel(panel.clone(), window, cx);
4890 panel
4891 });
4892
4893 cx.run_until_parked();
4894
4895 // Simulate worktree creation in progress and reset to Uninitialized
4896 panel.update_in(cx, |panel, window, cx| {
4897 panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
4898 panel.active_view = ActiveView::Uninitialized;
4899 Panel::set_active(panel, true, window, cx);
4900 assert!(
4901 matches!(panel.active_view, ActiveView::Uninitialized),
4902 "set_active should not create a thread while worktree is being created"
4903 );
4904 });
4905
4906 // Clear the creation status and use open_external_thread_with_server
4907 // (which bypasses new_agent_thread) to verify the panel can transition
4908 // out of Uninitialized. We can't call set_active directly because
4909 // new_agent_thread requires full agent server infrastructure.
4910 panel.update_in(cx, |panel, window, cx| {
4911 panel.worktree_creation_status = None;
4912 panel.active_view = ActiveView::Uninitialized;
4913 panel.open_external_thread_with_server(
4914 Rc::new(StubAgentServer::default_response()),
4915 window,
4916 cx,
4917 );
4918 });
4919
4920 cx.run_until_parked();
4921
4922 panel.read_with(cx, |panel, _cx| {
4923 assert!(
4924 !matches!(panel.active_view, ActiveView::Uninitialized),
4925 "panel should transition out of Uninitialized once worktree creation is cleared"
4926 );
4927 });
4928 }
4929
4930 #[test]
4931 fn test_deserialize_legacy_agent_type_variants() {
4932 assert_eq!(
4933 serde_json::from_str::<AgentType>(r#""ClaudeAgent""#).unwrap(),
4934 AgentType::Custom {
4935 name: CLAUDE_AGENT_NAME.into(),
4936 },
4937 );
4938 assert_eq!(
4939 serde_json::from_str::<AgentType>(r#""ClaudeCode""#).unwrap(),
4940 AgentType::Custom {
4941 name: CLAUDE_AGENT_NAME.into(),
4942 },
4943 );
4944 assert_eq!(
4945 serde_json::from_str::<AgentType>(r#""Codex""#).unwrap(),
4946 AgentType::Custom {
4947 name: CODEX_NAME.into(),
4948 },
4949 );
4950 assert_eq!(
4951 serde_json::from_str::<AgentType>(r#""Gemini""#).unwrap(),
4952 AgentType::Custom {
4953 name: GEMINI_NAME.into(),
4954 },
4955 );
4956 }
4957
4958 #[test]
4959 fn test_deserialize_current_agent_type_variants() {
4960 assert_eq!(
4961 serde_json::from_str::<AgentType>(r#""NativeAgent""#).unwrap(),
4962 AgentType::NativeAgent,
4963 );
4964 assert_eq!(
4965 serde_json::from_str::<AgentType>(r#""TextThread""#).unwrap(),
4966 AgentType::TextThread,
4967 );
4968 assert_eq!(
4969 serde_json::from_str::<AgentType>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
4970 AgentType::Custom {
4971 name: "my-agent".into(),
4972 },
4973 );
4974 }
4975
4976 #[test]
4977 fn test_deserialize_legacy_serialized_panel() {
4978 let json = serde_json::json!({
4979 "width": 300.0,
4980 "selected_agent": "ClaudeAgent",
4981 "last_active_thread": {
4982 "session_id": "test-session",
4983 "agent_type": "Codex",
4984 },
4985 });
4986
4987 let panel: SerializedAgentPanel = serde_json::from_value(json).unwrap();
4988 assert_eq!(
4989 panel.selected_agent,
4990 Some(AgentType::Custom {
4991 name: CLAUDE_AGENT_NAME.into(),
4992 }),
4993 );
4994 let thread = panel.last_active_thread.unwrap();
4995 assert_eq!(
4996 thread.agent_type,
4997 AgentType::Custom {
4998 name: CODEX_NAME.into(),
4999 },
5000 );
5001 }
5002}