1use std::{
2 ops::Range,
3 path::Path,
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 project::{
18 ExternalAgentServerName,
19 agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME},
20};
21use serde::{Deserialize, Serialize};
22use settings::{LanguageModelProviderSetting, LanguageModelSelection};
23
24use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff};
25
26use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
27use crate::{
28 AddContextServer, AgentDiffPane, CopyThreadToClipboard, Follow, InlineAssistant,
29 LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
30 OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu,
31 ToggleOptionsMenu,
32 acp::AcpServerView,
33 agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
34 slash_command::SlashCommandCompletionProvider,
35 text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate},
36 ui::EndTrialUpsell,
37};
38use crate::{
39 AgentInitialContent, ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary,
40};
41use crate::{
42 ExpandMessageEditor,
43 acp::{AcpThreadHistory, ThreadHistoryEvent},
44 text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
45};
46use crate::{ManageProfiles, acp::thread_view::AcpThreadView};
47use agent_settings::AgentSettings;
48use ai_onboarding::AgentPanelOnboarding;
49use anyhow::{Result, anyhow};
50use assistant_slash_command::SlashCommandWorkingSet;
51use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
52use client::UserStore;
53use cloud_api_types::Plan;
54use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
55use extension::ExtensionEvents;
56use extension_host::ExtensionStore;
57use fs::Fs;
58use gpui::{
59 Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
60 DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
61 Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
62};
63use language::LanguageRegistry;
64use language_model::{ConfigurationError, LanguageModelRegistry};
65use project::{Project, ProjectPath, Worktree};
66use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
67use rules_library::{RulesLibrary, open_rules_library};
68use search::{BufferSearchBar, buffer_search};
69use settings::{Settings, update_settings_file};
70use theme::ThemeSettings;
71use ui::{
72 Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab,
73 Tooltip, prelude::*, utils::WithRemSize,
74};
75use util::ResultExt as _;
76use workspace::{
77 CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
78 WorkspaceId,
79 dock::{DockPosition, Panel, PanelEvent},
80};
81use zed_actions::{
82 DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
83 agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
84 assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
85};
86
87const AGENT_PANEL_KEY: &str = "agent_panel";
88const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
89const DEFAULT_THREAD_TITLE: &str = "New Thread";
90
91fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
92 let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
93 let key = i64::from(workspace_id).to_string();
94 scope
95 .read(&key)
96 .log_err()
97 .flatten()
98 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
99}
100
101async fn save_serialized_panel(
102 workspace_id: workspace::WorkspaceId,
103 panel: SerializedAgentPanel,
104) -> Result<()> {
105 let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
106 let key = i64::from(workspace_id).to_string();
107 scope.write(key, serde_json::to_string(&panel)?).await?;
108 Ok(())
109}
110
111/// Migration: reads the original single-panel format stored under the
112/// `"agent_panel"` KVP key before per-workspace keying was introduced.
113fn read_legacy_serialized_panel() -> Option<SerializedAgentPanel> {
114 KEY_VALUE_STORE
115 .read_kvp(AGENT_PANEL_KEY)
116 .log_err()
117 .flatten()
118 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
119}
120
121#[derive(Serialize, Deserialize, Debug, Clone)]
122struct SerializedAgentPanel {
123 width: Option<Pixels>,
124 selected_agent: Option<AgentType>,
125 #[serde(default)]
126 last_active_thread: Option<SerializedActiveThread>,
127}
128
129#[derive(Serialize, Deserialize, Debug, Clone)]
130struct SerializedActiveThread {
131 session_id: String,
132 agent_type: AgentType,
133 title: Option<String>,
134 cwd: Option<std::path::PathBuf>,
135}
136
137pub fn init(cx: &mut App) {
138 cx.observe_new(
139 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
140 workspace
141 .register_action(|workspace, action: &NewThread, window, cx| {
142 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
143 panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
144 workspace.focus_panel::<AgentPanel>(window, cx);
145 }
146 })
147 .register_action(
148 |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
149 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
150 panel.update(cx, |panel, cx| {
151 panel.new_native_agent_thread_from_summary(action, window, cx)
152 });
153 workspace.focus_panel::<AgentPanel>(window, cx);
154 }
155 },
156 )
157 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
158 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
159 workspace.focus_panel::<AgentPanel>(window, cx);
160 panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
161 }
162 })
163 .register_action(|workspace, _: &OpenHistory, 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.open_history(window, cx));
167 }
168 })
169 .register_action(|workspace, _: &OpenSettings, 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_configuration(window, cx));
173 }
174 })
175 .register_action(|workspace, _: &NewTextThread, window, cx| {
176 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
177 workspace.focus_panel::<AgentPanel>(window, cx);
178 panel.update(cx, |panel, cx| {
179 panel.new_text_thread(window, cx);
180 });
181 }
182 })
183 .register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
184 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
185 workspace.focus_panel::<AgentPanel>(window, cx);
186 panel.update(cx, |panel, cx| {
187 panel.external_thread(action.agent.clone(), None, None, window, cx)
188 });
189 }
190 })
191 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
192 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
193 workspace.focus_panel::<AgentPanel>(window, cx);
194 panel.update(cx, |panel, cx| {
195 panel.deploy_rules_library(action, window, cx)
196 });
197 }
198 })
199 .register_action(|workspace, _: &Follow, window, cx| {
200 workspace.follow(CollaboratorId::Agent, window, cx);
201 })
202 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
203 let thread = workspace
204 .panel::<AgentPanel>(cx)
205 .and_then(|panel| panel.read(cx).active_thread_view().cloned())
206 .and_then(|thread_view| {
207 thread_view
208 .read(cx)
209 .active_thread()
210 .map(|r| r.read(cx).thread.clone())
211 });
212
213 if let Some(thread) = thread {
214 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
215 }
216 })
217 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
218 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
219 workspace.focus_panel::<AgentPanel>(window, cx);
220 panel.update(cx, |panel, cx| {
221 panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
222 });
223 }
224 })
225 .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
226 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
227 workspace.focus_panel::<AgentPanel>(window, cx);
228 panel.update(cx, |panel, cx| {
229 panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
230 });
231 }
232 })
233 .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
234 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
235 workspace.focus_panel::<AgentPanel>(window, cx);
236 panel.update(cx, |panel, cx| {
237 panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
238 });
239 }
240 })
241 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
242 AcpOnboardingModal::toggle(workspace, window, cx)
243 })
244 .register_action(
245 |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| {
246 ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
247 },
248 )
249 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
250 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
251 window.refresh();
252 })
253 .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
254 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
255 panel.update(cx, |panel, _| {
256 panel
257 .on_boarding_upsell_dismissed
258 .store(false, Ordering::Release);
259 });
260 }
261 OnboardingUpsell::set_dismissed(false, cx);
262 })
263 .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
264 TrialEndUpsell::set_dismissed(false, cx);
265 })
266 .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
267 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
268 panel.update(cx, |panel, cx| {
269 panel.reset_agent_zoom(window, cx);
270 });
271 }
272 })
273 .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
274 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
275 panel.update(cx, |panel, cx| {
276 panel.copy_thread_to_clipboard(window, cx);
277 });
278 }
279 })
280 .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
281 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
282 workspace.focus_panel::<AgentPanel>(window, cx);
283 panel.update(cx, |panel, cx| {
284 panel.load_thread_from_clipboard(window, cx);
285 });
286 }
287 })
288 .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
289 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
290 return;
291 };
292
293 let mention_uri = MentionUri::GitDiff {
294 base_ref: action.base_ref.to_string(),
295 };
296 let diff_uri = mention_uri.to_uri().to_string();
297
298 let content_blocks = vec![
299 acp::ContentBlock::Text(acp::TextContent::new(
300 "Please review this branch diff carefully. Point out any issues, \
301 potential bugs, or improvement opportunities you find.\n\n"
302 .to_string(),
303 )),
304 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
305 acp::EmbeddedResourceResource::TextResourceContents(
306 acp::TextResourceContents::new(
307 action.diff_text.to_string(),
308 diff_uri,
309 ),
310 ),
311 )),
312 ];
313
314 workspace.focus_panel::<AgentPanel>(window, cx);
315
316 panel.update(cx, |panel, cx| {
317 panel.external_thread(
318 None,
319 None,
320 Some(AgentInitialContent::ContentBlock {
321 blocks: content_blocks,
322 auto_submit: true,
323 }),
324 window,
325 cx,
326 );
327 });
328 });
329 },
330 )
331 .detach();
332}
333
334#[derive(Clone, Copy, Debug, PartialEq, Eq)]
335enum HistoryKind {
336 AgentThreads,
337 TextThreads,
338}
339
340enum ActiveView {
341 Uninitialized,
342 AgentThread {
343 server_view: Entity<AcpServerView>,
344 },
345 TextThread {
346 text_thread_editor: Entity<TextThreadEditor>,
347 title_editor: Entity<Editor>,
348 buffer_search_bar: Entity<BufferSearchBar>,
349 _subscriptions: Vec<gpui::Subscription>,
350 },
351 History {
352 kind: HistoryKind,
353 },
354 Configuration,
355}
356
357enum WhichFontSize {
358 AgentFont,
359 BufferFont,
360 None,
361}
362
363// TODO unify this with ExternalAgent
364#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
365pub enum AgentType {
366 #[default]
367 NativeAgent,
368 TextThread,
369 Gemini,
370 ClaudeAgent,
371 Codex,
372 Custom {
373 name: SharedString,
374 },
375}
376
377impl AgentType {
378 fn label(&self) -> SharedString {
379 match self {
380 Self::NativeAgent | Self::TextThread => "Zed Agent".into(),
381 Self::Gemini => "Gemini CLI".into(),
382 Self::ClaudeAgent => "Claude Agent".into(),
383 Self::Codex => "Codex".into(),
384 Self::Custom { name, .. } => name.into(),
385 }
386 }
387
388 fn icon(&self) -> Option<IconName> {
389 match self {
390 Self::NativeAgent | Self::TextThread => None,
391 Self::Gemini => Some(IconName::AiGemini),
392 Self::ClaudeAgent => Some(IconName::AiClaude),
393 Self::Codex => Some(IconName::AiOpenAi),
394 Self::Custom { .. } => Some(IconName::Sparkle),
395 }
396 }
397}
398
399impl From<ExternalAgent> for AgentType {
400 fn from(value: ExternalAgent) -> Self {
401 match value {
402 ExternalAgent::Gemini => Self::Gemini,
403 ExternalAgent::ClaudeCode => Self::ClaudeAgent,
404 ExternalAgent::Codex => Self::Codex,
405 ExternalAgent::Custom { name } => Self::Custom { name },
406 ExternalAgent::NativeAgent => Self::NativeAgent,
407 }
408 }
409}
410
411impl ActiveView {
412 pub fn which_font_size_used(&self) -> WhichFontSize {
413 match self {
414 ActiveView::Uninitialized
415 | ActiveView::AgentThread { .. }
416 | ActiveView::History { .. } => WhichFontSize::AgentFont,
417 ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
418 ActiveView::Configuration => WhichFontSize::None,
419 }
420 }
421
422 pub fn text_thread(
423 text_thread_editor: Entity<TextThreadEditor>,
424 language_registry: Arc<LanguageRegistry>,
425 window: &mut Window,
426 cx: &mut App,
427 ) -> Self {
428 let title = text_thread_editor.read(cx).title(cx).to_string();
429
430 let editor = cx.new(|cx| {
431 let mut editor = Editor::single_line(window, cx);
432 editor.set_text(title, window, cx);
433 editor
434 });
435
436 // This is a workaround for `editor.set_text` emitting a `BufferEdited` event, which would
437 // cause a custom summary to be set. The presence of this custom summary would cause
438 // summarization to not happen.
439 let mut suppress_first_edit = true;
440
441 let subscriptions = vec![
442 window.subscribe(&editor, cx, {
443 {
444 let text_thread_editor = text_thread_editor.clone();
445 move |editor, event, window, cx| match event {
446 EditorEvent::BufferEdited => {
447 if suppress_first_edit {
448 suppress_first_edit = false;
449 return;
450 }
451 let new_summary = editor.read(cx).text(cx);
452
453 text_thread_editor.update(cx, |text_thread_editor, cx| {
454 text_thread_editor
455 .text_thread()
456 .update(cx, |text_thread, cx| {
457 text_thread.set_custom_summary(new_summary, cx);
458 })
459 })
460 }
461 EditorEvent::Blurred => {
462 if editor.read(cx).text(cx).is_empty() {
463 let summary = text_thread_editor
464 .read(cx)
465 .text_thread()
466 .read(cx)
467 .summary()
468 .or_default();
469
470 editor.update(cx, |editor, cx| {
471 editor.set_text(summary, window, cx);
472 });
473 }
474 }
475 _ => {}
476 }
477 }
478 }),
479 window.subscribe(&text_thread_editor.read(cx).text_thread().clone(), cx, {
480 let editor = editor.clone();
481 move |text_thread, event, window, cx| match event {
482 TextThreadEvent::SummaryGenerated => {
483 let summary = text_thread.read(cx).summary().or_default();
484
485 editor.update(cx, |editor, cx| {
486 editor.set_text(summary, window, cx);
487 })
488 }
489 TextThreadEvent::PathChanged { .. } => {}
490 _ => {}
491 }
492 }),
493 ];
494
495 let buffer_search_bar =
496 cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx));
497 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
498 buffer_search_bar.set_active_pane_item(Some(&text_thread_editor), window, cx)
499 });
500
501 Self::TextThread {
502 text_thread_editor,
503 title_editor: editor,
504 buffer_search_bar,
505 _subscriptions: subscriptions,
506 }
507 }
508}
509
510pub struct AgentPanel {
511 workspace: WeakEntity<Workspace>,
512 /// Workspace id is used as a database key
513 workspace_id: Option<WorkspaceId>,
514 user_store: Entity<UserStore>,
515 project: Entity<Project>,
516 fs: Arc<dyn Fs>,
517 language_registry: Arc<LanguageRegistry>,
518 acp_history: Entity<AcpThreadHistory>,
519 text_thread_history: Entity<TextThreadHistory>,
520 thread_store: Entity<ThreadStore>,
521 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
522 prompt_store: Option<Entity<PromptStore>>,
523 context_server_registry: Entity<ContextServerRegistry>,
524 configuration: Option<Entity<AgentConfiguration>>,
525 configuration_subscription: Option<Subscription>,
526 focus_handle: FocusHandle,
527 active_view: ActiveView,
528 previous_view: Option<ActiveView>,
529 _active_view_observation: Option<Subscription>,
530 new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
531 agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
532 agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
533 agent_navigation_menu: Option<Entity<ContextMenu>>,
534 _extension_subscription: Option<Subscription>,
535 width: Option<Pixels>,
536 height: Option<Pixels>,
537 zoomed: bool,
538 pending_serialization: Option<Task<Result<()>>>,
539 onboarding: Entity<AgentPanelOnboarding>,
540 selected_agent: AgentType,
541 show_trust_workspace_message: bool,
542 last_configuration_error_telemetry: Option<String>,
543 on_boarding_upsell_dismissed: AtomicBool,
544}
545
546impl AgentPanel {
547 fn serialize(&mut self, cx: &mut App) {
548 let Some(workspace_id) = self.workspace_id else {
549 return;
550 };
551
552 let width = self.width;
553 let selected_agent = self.selected_agent.clone();
554
555 let last_active_thread = self.active_agent_thread(cx).map(|thread| {
556 let thread = thread.read(cx);
557 let title = thread.title();
558 SerializedActiveThread {
559 session_id: thread.session_id().0.to_string(),
560 agent_type: self.selected_agent.clone(),
561 title: if title.as_ref() != DEFAULT_THREAD_TITLE {
562 Some(title.to_string())
563 } else {
564 None
565 },
566 cwd: None,
567 }
568 });
569
570 self.pending_serialization = Some(cx.background_spawn(async move {
571 save_serialized_panel(
572 workspace_id,
573 SerializedAgentPanel {
574 width,
575 selected_agent: Some(selected_agent),
576 last_active_thread,
577 },
578 )
579 .await?;
580 anyhow::Ok(())
581 }));
582 }
583
584 pub fn load(
585 workspace: WeakEntity<Workspace>,
586 prompt_builder: Arc<PromptBuilder>,
587 mut cx: AsyncWindowContext,
588 ) -> Task<Result<Entity<Self>>> {
589 let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
590 cx.spawn(async move |cx| {
591 let prompt_store = match prompt_store {
592 Ok(prompt_store) => prompt_store.await.ok(),
593 Err(_) => None,
594 };
595 let workspace_id = workspace
596 .read_with(cx, |workspace, _| workspace.database_id())
597 .ok()
598 .flatten();
599
600 let serialized_panel = cx
601 .background_spawn(async move {
602 workspace_id
603 .and_then(read_serialized_panel)
604 .or_else(read_legacy_serialized_panel)
605 })
606 .await;
607
608 let slash_commands = Arc::new(SlashCommandWorkingSet::default());
609 let text_thread_store = workspace
610 .update(cx, |workspace, cx| {
611 let project = workspace.project().clone();
612 assistant_text_thread::TextThreadStore::new(
613 project,
614 prompt_builder,
615 slash_commands,
616 cx,
617 )
618 })?
619 .await?;
620
621 let panel = workspace.update_in(cx, |workspace, window, cx| {
622 let panel =
623 cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
624
625 if let Some(serialized_panel) = &serialized_panel {
626 panel.update(cx, |panel, cx| {
627 panel.width = serialized_panel.width.map(|w| w.round());
628 if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
629 panel.selected_agent = selected_agent;
630 }
631 cx.notify();
632 });
633 }
634
635 panel
636 })?;
637
638 if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) {
639 let session_id = acp::SessionId::new(thread_info.session_id.clone());
640 let load_task = panel.update(cx, |panel, cx| {
641 let thread_store = panel.thread_store.clone();
642 thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
643 });
644 let thread_exists = load_task
645 .await
646 .map(|thread: Option<agent::DbThread>| thread.is_some())
647 .unwrap_or(false);
648
649 if thread_exists {
650 panel.update_in(cx, |panel, window, cx| {
651 panel.selected_agent = thread_info.agent_type.clone();
652 let session_info = AgentSessionInfo {
653 session_id: acp::SessionId::new(thread_info.session_id),
654 cwd: thread_info.cwd,
655 title: thread_info.title.map(SharedString::from),
656 updated_at: None,
657 meta: None,
658 };
659 panel.load_agent_thread(session_info, window, cx);
660 })?;
661 } else {
662 log::error!(
663 "could not restore last active thread: \
664 no thread found in database with ID {:?}",
665 thread_info.session_id
666 );
667 }
668 }
669
670 Ok(panel)
671 })
672 }
673
674 pub(crate) fn new(
675 workspace: &Workspace,
676 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
677 prompt_store: Option<Entity<PromptStore>>,
678 window: &mut Window,
679 cx: &mut Context<Self>,
680 ) -> Self {
681 let fs = workspace.app_state().fs.clone();
682 let user_store = workspace.app_state().user_store.clone();
683 let project = workspace.project();
684 let language_registry = project.read(cx).languages().clone();
685 let client = workspace.client().clone();
686 let workspace_id = workspace.database_id();
687 let workspace = workspace.weak_handle();
688
689 let context_server_registry =
690 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
691
692 let thread_store = ThreadStore::global(cx);
693 let acp_history = cx.new(|cx| AcpThreadHistory::new(None, window, cx));
694 let text_thread_history =
695 cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
696 cx.subscribe_in(
697 &acp_history,
698 window,
699 |this, _, event, window, cx| match event {
700 ThreadHistoryEvent::Open(thread) => {
701 this.load_agent_thread(thread.clone(), window, cx);
702 }
703 },
704 )
705 .detach();
706 cx.subscribe_in(
707 &text_thread_history,
708 window,
709 |this, _, event, window, cx| match event {
710 TextThreadHistoryEvent::Open(thread) => {
711 this.open_saved_text_thread(thread.path.clone(), window, cx)
712 .detach_and_log_err(cx);
713 }
714 },
715 )
716 .detach();
717
718 let active_view = ActiveView::Uninitialized;
719
720 let weak_panel = cx.entity().downgrade();
721
722 window.defer(cx, move |window, cx| {
723 let panel = weak_panel.clone();
724 let agent_navigation_menu =
725 ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
726 if let Some(panel) = panel.upgrade() {
727 if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) {
728 menu =
729 Self::populate_recently_updated_menu_section(menu, panel, kind, cx);
730 let view_all_label = match kind {
731 HistoryKind::AgentThreads => "View All",
732 HistoryKind::TextThreads => "View All Text Threads",
733 };
734 menu = menu.action(view_all_label, Box::new(OpenHistory));
735 }
736 }
737
738 menu = menu
739 .fixed_width(px(320.).into())
740 .keep_open_on_confirm(false)
741 .key_context("NavigationMenu");
742
743 menu
744 });
745 weak_panel
746 .update(cx, |panel, cx| {
747 cx.subscribe_in(
748 &agent_navigation_menu,
749 window,
750 |_, menu, _: &DismissEvent, window, cx| {
751 menu.update(cx, |menu, _| {
752 menu.clear_selected();
753 });
754 cx.focus_self(window);
755 },
756 )
757 .detach();
758 panel.agent_navigation_menu = Some(agent_navigation_menu);
759 })
760 .ok();
761 });
762
763 let weak_panel = cx.entity().downgrade();
764 let onboarding = cx.new(|cx| {
765 AgentPanelOnboarding::new(
766 user_store.clone(),
767 client,
768 move |_window, cx| {
769 weak_panel
770 .update(cx, |panel, _| {
771 panel
772 .on_boarding_upsell_dismissed
773 .store(true, Ordering::Release);
774 })
775 .ok();
776 OnboardingUpsell::set_dismissed(true, cx);
777 },
778 cx,
779 )
780 });
781
782 // Subscribe to extension events to sync agent servers when extensions change
783 let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
784 {
785 Some(
786 cx.subscribe(&extension_events, |this, _source, event, cx| match event {
787 extension::Event::ExtensionInstalled(_)
788 | extension::Event::ExtensionUninstalled(_)
789 | extension::Event::ExtensionsInstalledChanged => {
790 this.sync_agent_servers_from_extensions(cx);
791 }
792 _ => {}
793 }),
794 )
795 } else {
796 None
797 };
798
799 let mut panel = Self {
800 workspace_id,
801 active_view,
802 workspace,
803 user_store,
804 project: project.clone(),
805 fs: fs.clone(),
806 language_registry,
807 text_thread_store,
808 prompt_store,
809 configuration: None,
810 configuration_subscription: None,
811 focus_handle: cx.focus_handle(),
812 context_server_registry,
813 previous_view: None,
814 _active_view_observation: None,
815 new_thread_menu_handle: PopoverMenuHandle::default(),
816 agent_panel_menu_handle: PopoverMenuHandle::default(),
817 agent_navigation_menu_handle: PopoverMenuHandle::default(),
818 agent_navigation_menu: None,
819 _extension_subscription: extension_subscription,
820 width: None,
821 height: None,
822 zoomed: false,
823 pending_serialization: None,
824 onboarding,
825 acp_history,
826 text_thread_history,
827 thread_store,
828 selected_agent: AgentType::default(),
829 show_trust_workspace_message: false,
830 last_configuration_error_telemetry: None,
831 on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
832 };
833
834 // Initial sync of agent servers from extensions
835 panel.sync_agent_servers_from_extensions(cx);
836 panel
837 }
838
839 pub fn toggle_focus(
840 workspace: &mut Workspace,
841 _: &ToggleFocus,
842 window: &mut Window,
843 cx: &mut Context<Workspace>,
844 ) {
845 if workspace
846 .panel::<Self>(cx)
847 .is_some_and(|panel| panel.read(cx).enabled(cx))
848 {
849 workspace.toggle_panel_focus::<Self>(window, cx);
850 }
851 }
852
853 pub fn toggle(
854 workspace: &mut Workspace,
855 _: &Toggle,
856 window: &mut Window,
857 cx: &mut Context<Workspace>,
858 ) {
859 if workspace
860 .panel::<Self>(cx)
861 .is_some_and(|panel| panel.read(cx).enabled(cx))
862 {
863 if !workspace.toggle_panel_focus::<Self>(window, cx) {
864 workspace.close_panel::<Self>(window, cx);
865 }
866 }
867 }
868
869 pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
870 &self.prompt_store
871 }
872
873 pub fn thread_store(&self) -> &Entity<ThreadStore> {
874 &self.thread_store
875 }
876
877 pub fn history(&self) -> &Entity<AcpThreadHistory> {
878 &self.acp_history
879 }
880
881 pub fn open_thread(
882 &mut self,
883 thread: AgentSessionInfo,
884 window: &mut Window,
885 cx: &mut Context<Self>,
886 ) {
887 self.external_thread(
888 Some(crate::ExternalAgent::NativeAgent),
889 Some(thread),
890 None,
891 window,
892 cx,
893 );
894 }
895
896 pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
897 &self.context_server_registry
898 }
899
900 pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
901 let workspace_read = workspace.read(cx);
902
903 workspace_read
904 .panel::<AgentPanel>(cx)
905 .map(|panel| {
906 let panel_id = Entity::entity_id(&panel);
907
908 workspace_read.all_docks().iter().any(|dock| {
909 dock.read(cx)
910 .visible_panel()
911 .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
912 })
913 })
914 .unwrap_or(false)
915 }
916
917 pub(crate) fn active_thread_view(&self) -> Option<&Entity<AcpServerView>> {
918 match &self.active_view {
919 ActiveView::AgentThread { server_view, .. } => Some(server_view),
920 ActiveView::Uninitialized
921 | ActiveView::TextThread { .. }
922 | ActiveView::History { .. }
923 | ActiveView::Configuration => None,
924 }
925 }
926
927 fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
928 self.new_agent_thread(AgentType::NativeAgent, window, cx);
929 }
930
931 fn new_native_agent_thread_from_summary(
932 &mut self,
933 action: &NewNativeAgentThreadFromSummary,
934 window: &mut Window,
935 cx: &mut Context<Self>,
936 ) {
937 let Some(thread) = self
938 .acp_history
939 .read(cx)
940 .session_for_id(&action.from_session_id)
941 else {
942 return;
943 };
944
945 self.external_thread(
946 Some(ExternalAgent::NativeAgent),
947 None,
948 Some(AgentInitialContent::ThreadSummary(thread)),
949 window,
950 cx,
951 );
952 }
953
954 fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
955 telemetry::event!("Agent Thread Started", agent = "zed-text");
956
957 let context = self
958 .text_thread_store
959 .update(cx, |context_store, cx| context_store.create(cx));
960 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx)
961 .log_err()
962 .flatten();
963
964 let text_thread_editor = cx.new(|cx| {
965 let mut editor = TextThreadEditor::for_text_thread(
966 context,
967 self.fs.clone(),
968 self.workspace.clone(),
969 self.project.clone(),
970 lsp_adapter_delegate,
971 window,
972 cx,
973 );
974 editor.insert_default_prompt(window, cx);
975 editor
976 });
977
978 if self.selected_agent != AgentType::TextThread {
979 self.selected_agent = AgentType::TextThread;
980 self.serialize(cx);
981 }
982
983 self.set_active_view(
984 ActiveView::text_thread(
985 text_thread_editor.clone(),
986 self.language_registry.clone(),
987 window,
988 cx,
989 ),
990 true,
991 window,
992 cx,
993 );
994 text_thread_editor.focus_handle(cx).focus(window, cx);
995 }
996
997 fn external_thread(
998 &mut self,
999 agent_choice: Option<crate::ExternalAgent>,
1000 resume_thread: Option<AgentSessionInfo>,
1001 initial_content: Option<AgentInitialContent>,
1002 window: &mut Window,
1003 cx: &mut Context<Self>,
1004 ) {
1005 let workspace = self.workspace.clone();
1006 let project = self.project.clone();
1007 let fs = self.fs.clone();
1008 let is_via_collab = self.project.read(cx).is_via_collab();
1009
1010 const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
1011
1012 #[derive(Serialize, Deserialize)]
1013 struct LastUsedExternalAgent {
1014 agent: crate::ExternalAgent,
1015 }
1016
1017 let thread_store = self.thread_store.clone();
1018
1019 cx.spawn_in(window, async move |this, cx| {
1020 let ext_agent = match agent_choice {
1021 Some(agent) => {
1022 cx.background_spawn({
1023 let agent = agent.clone();
1024 async move {
1025 if let Some(serialized) =
1026 serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
1027 {
1028 KEY_VALUE_STORE
1029 .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
1030 .await
1031 .log_err();
1032 }
1033 }
1034 })
1035 .detach();
1036
1037 agent
1038 }
1039 None => {
1040 if is_via_collab {
1041 ExternalAgent::NativeAgent
1042 } else {
1043 cx.background_spawn(async move {
1044 KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
1045 })
1046 .await
1047 .log_err()
1048 .flatten()
1049 .and_then(|value| {
1050 serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
1051 })
1052 .map(|agent| agent.agent)
1053 .unwrap_or(ExternalAgent::NativeAgent)
1054 }
1055 }
1056 };
1057
1058 let server = ext_agent.server(fs, thread_store);
1059 this.update_in(cx, |agent_panel, window, cx| {
1060 agent_panel._external_thread(
1061 server,
1062 resume_thread,
1063 initial_content,
1064 workspace,
1065 project,
1066 ext_agent,
1067 window,
1068 cx,
1069 );
1070 })?;
1071
1072 anyhow::Ok(())
1073 })
1074 .detach_and_log_err(cx);
1075 }
1076
1077 fn deploy_rules_library(
1078 &mut self,
1079 action: &OpenRulesLibrary,
1080 _window: &mut Window,
1081 cx: &mut Context<Self>,
1082 ) {
1083 open_rules_library(
1084 self.language_registry.clone(),
1085 Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1086 Rc::new(|| {
1087 Rc::new(SlashCommandCompletionProvider::new(
1088 Arc::new(SlashCommandWorkingSet::default()),
1089 None,
1090 None,
1091 ))
1092 }),
1093 action
1094 .prompt_to_select
1095 .map(|uuid| UserPromptId(uuid).into()),
1096 cx,
1097 )
1098 .detach_and_log_err(cx);
1099 }
1100
1101 fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1102 let Some(thread_view) = self.active_thread_view() else {
1103 return;
1104 };
1105
1106 let Some(active_thread) = thread_view.read(cx).active_thread().cloned() else {
1107 return;
1108 };
1109
1110 active_thread.update(cx, |active_thread, cx| {
1111 active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1112 active_thread.focus_handle(cx).focus(window, cx);
1113 })
1114 }
1115
1116 fn history_kind_for_selected_agent(&self, cx: &App) -> Option<HistoryKind> {
1117 match self.selected_agent {
1118 AgentType::NativeAgent => Some(HistoryKind::AgentThreads),
1119 AgentType::TextThread => Some(HistoryKind::TextThreads),
1120 AgentType::Gemini
1121 | AgentType::ClaudeAgent
1122 | AgentType::Codex
1123 | AgentType::Custom { .. } => {
1124 if self.acp_history.read(cx).has_session_list() {
1125 Some(HistoryKind::AgentThreads)
1126 } else {
1127 None
1128 }
1129 }
1130 }
1131 }
1132
1133 fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1134 let Some(kind) = self.history_kind_for_selected_agent(cx) else {
1135 return;
1136 };
1137
1138 if let ActiveView::History { kind: active_kind } = self.active_view {
1139 if active_kind == kind {
1140 if let Some(previous_view) = self.previous_view.take() {
1141 self.set_active_view(previous_view, true, window, cx);
1142 }
1143 return;
1144 }
1145 }
1146
1147 self.set_active_view(ActiveView::History { kind }, true, window, cx);
1148 cx.notify();
1149 }
1150
1151 pub(crate) fn open_saved_text_thread(
1152 &mut self,
1153 path: Arc<Path>,
1154 window: &mut Window,
1155 cx: &mut Context<Self>,
1156 ) -> Task<Result<()>> {
1157 let text_thread_task = self
1158 .text_thread_store
1159 .update(cx, |store, cx| store.open_local(path, cx));
1160 cx.spawn_in(window, async move |this, cx| {
1161 let text_thread = text_thread_task.await?;
1162 this.update_in(cx, |this, window, cx| {
1163 this.open_text_thread(text_thread, window, cx);
1164 })
1165 })
1166 }
1167
1168 pub(crate) fn open_text_thread(
1169 &mut self,
1170 text_thread: Entity<TextThread>,
1171 window: &mut Window,
1172 cx: &mut Context<Self>,
1173 ) {
1174 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project.clone(), cx)
1175 .log_err()
1176 .flatten();
1177 let editor = cx.new(|cx| {
1178 TextThreadEditor::for_text_thread(
1179 text_thread,
1180 self.fs.clone(),
1181 self.workspace.clone(),
1182 self.project.clone(),
1183 lsp_adapter_delegate,
1184 window,
1185 cx,
1186 )
1187 });
1188
1189 if self.selected_agent != AgentType::TextThread {
1190 self.selected_agent = AgentType::TextThread;
1191 self.serialize(cx);
1192 }
1193
1194 self.set_active_view(
1195 ActiveView::text_thread(editor, self.language_registry.clone(), window, cx),
1196 true,
1197 window,
1198 cx,
1199 );
1200 }
1201
1202 pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1203 match self.active_view {
1204 ActiveView::Configuration | ActiveView::History { .. } => {
1205 if let Some(previous_view) = self.previous_view.take() {
1206 self.set_active_view(previous_view, true, window, cx);
1207 }
1208 cx.notify();
1209 }
1210 _ => {}
1211 }
1212 }
1213
1214 pub fn toggle_navigation_menu(
1215 &mut self,
1216 _: &ToggleNavigationMenu,
1217 window: &mut Window,
1218 cx: &mut Context<Self>,
1219 ) {
1220 if self.history_kind_for_selected_agent(cx).is_none() {
1221 return;
1222 }
1223 self.agent_navigation_menu_handle.toggle(window, cx);
1224 }
1225
1226 pub fn toggle_options_menu(
1227 &mut self,
1228 _: &ToggleOptionsMenu,
1229 window: &mut Window,
1230 cx: &mut Context<Self>,
1231 ) {
1232 self.agent_panel_menu_handle.toggle(window, cx);
1233 }
1234
1235 pub fn toggle_new_thread_menu(
1236 &mut self,
1237 _: &ToggleNewThreadMenu,
1238 window: &mut Window,
1239 cx: &mut Context<Self>,
1240 ) {
1241 self.new_thread_menu_handle.toggle(window, cx);
1242 }
1243
1244 pub fn increase_font_size(
1245 &mut self,
1246 action: &IncreaseBufferFontSize,
1247 _: &mut Window,
1248 cx: &mut Context<Self>,
1249 ) {
1250 self.handle_font_size_action(action.persist, px(1.0), cx);
1251 }
1252
1253 pub fn decrease_font_size(
1254 &mut self,
1255 action: &DecreaseBufferFontSize,
1256 _: &mut Window,
1257 cx: &mut Context<Self>,
1258 ) {
1259 self.handle_font_size_action(action.persist, px(-1.0), cx);
1260 }
1261
1262 fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1263 match self.active_view.which_font_size_used() {
1264 WhichFontSize::AgentFont => {
1265 if persist {
1266 update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1267 let agent_ui_font_size =
1268 ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1269 let agent_buffer_font_size =
1270 ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1271
1272 let _ = settings
1273 .theme
1274 .agent_ui_font_size
1275 .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into());
1276 let _ = settings.theme.agent_buffer_font_size.insert(
1277 f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(),
1278 );
1279 });
1280 } else {
1281 theme::adjust_agent_ui_font_size(cx, |size| size + delta);
1282 theme::adjust_agent_buffer_font_size(cx, |size| size + delta);
1283 }
1284 }
1285 WhichFontSize::BufferFont => {
1286 // Prompt editor uses the buffer font size, so allow the action to propagate to the
1287 // default handler that changes that font size.
1288 cx.propagate();
1289 }
1290 WhichFontSize::None => {}
1291 }
1292 }
1293
1294 pub fn reset_font_size(
1295 &mut self,
1296 action: &ResetBufferFontSize,
1297 _: &mut Window,
1298 cx: &mut Context<Self>,
1299 ) {
1300 if action.persist {
1301 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1302 settings.theme.agent_ui_font_size = None;
1303 settings.theme.agent_buffer_font_size = None;
1304 });
1305 } else {
1306 theme::reset_agent_ui_font_size(cx);
1307 theme::reset_agent_buffer_font_size(cx);
1308 }
1309 }
1310
1311 pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1312 theme::reset_agent_ui_font_size(cx);
1313 theme::reset_agent_buffer_font_size(cx);
1314 }
1315
1316 pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1317 if self.zoomed {
1318 cx.emit(PanelEvent::ZoomOut);
1319 } else {
1320 if !self.focus_handle(cx).contains_focused(window, cx) {
1321 cx.focus_self(window);
1322 }
1323 cx.emit(PanelEvent::ZoomIn);
1324 }
1325 }
1326
1327 pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1328 let agent_server_store = self.project.read(cx).agent_server_store().clone();
1329 let context_server_store = self.project.read(cx).context_server_store();
1330 let fs = self.fs.clone();
1331
1332 self.set_active_view(ActiveView::Configuration, true, window, cx);
1333 self.configuration = Some(cx.new(|cx| {
1334 AgentConfiguration::new(
1335 fs,
1336 agent_server_store,
1337 context_server_store,
1338 self.context_server_registry.clone(),
1339 self.language_registry.clone(),
1340 self.workspace.clone(),
1341 window,
1342 cx,
1343 )
1344 }));
1345
1346 if let Some(configuration) = self.configuration.as_ref() {
1347 self.configuration_subscription = Some(cx.subscribe_in(
1348 configuration,
1349 window,
1350 Self::handle_agent_configuration_event,
1351 ));
1352
1353 configuration.focus_handle(cx).focus(window, cx);
1354 }
1355 }
1356
1357 pub(crate) fn open_active_thread_as_markdown(
1358 &mut self,
1359 _: &OpenActiveThreadAsMarkdown,
1360 window: &mut Window,
1361 cx: &mut Context<Self>,
1362 ) {
1363 if let Some(workspace) = self.workspace.upgrade()
1364 && let Some(thread_view) = self.active_thread_view()
1365 && let Some(active_thread) = thread_view.read(cx).active_thread().cloned()
1366 {
1367 active_thread.update(cx, |thread, cx| {
1368 thread
1369 .open_thread_as_markdown(workspace, window, cx)
1370 .detach_and_log_err(cx);
1371 });
1372 }
1373 }
1374
1375 fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1376 let Some(thread) = self.active_native_agent_thread(cx) else {
1377 Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1378 return;
1379 };
1380
1381 let workspace = self.workspace.clone();
1382 let load_task = thread.read(cx).to_db(cx);
1383
1384 cx.spawn_in(window, async move |_this, cx| {
1385 let db_thread = load_task.await;
1386 let shared_thread = SharedThread::from_db_thread(&db_thread);
1387 let thread_data = shared_thread.to_bytes()?;
1388 let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1389
1390 cx.update(|_window, cx| {
1391 cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1392 if let Some(workspace) = workspace.upgrade() {
1393 workspace.update(cx, |workspace, cx| {
1394 struct ThreadCopiedToast;
1395 workspace.show_toast(
1396 workspace::Toast::new(
1397 workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1398 "Thread copied to clipboard (base64 encoded)",
1399 )
1400 .autohide(),
1401 cx,
1402 );
1403 });
1404 }
1405 })?;
1406
1407 anyhow::Ok(())
1408 })
1409 .detach_and_log_err(cx);
1410 }
1411
1412 fn show_deferred_toast(
1413 workspace: &WeakEntity<workspace::Workspace>,
1414 message: &'static str,
1415 cx: &mut App,
1416 ) {
1417 let workspace = workspace.clone();
1418 cx.defer(move |cx| {
1419 if let Some(workspace) = workspace.upgrade() {
1420 workspace.update(cx, |workspace, cx| {
1421 struct ClipboardToast;
1422 workspace.show_toast(
1423 workspace::Toast::new(
1424 workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1425 message,
1426 )
1427 .autohide(),
1428 cx,
1429 );
1430 });
1431 }
1432 });
1433 }
1434
1435 fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1436 let Some(clipboard) = cx.read_from_clipboard() else {
1437 Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1438 return;
1439 };
1440
1441 let Some(encoded) = clipboard.text() else {
1442 Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1443 return;
1444 };
1445
1446 let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1447 {
1448 Ok(data) => data,
1449 Err(_) => {
1450 Self::show_deferred_toast(
1451 &self.workspace,
1452 "Failed to decode clipboard content (expected base64)",
1453 cx,
1454 );
1455 return;
1456 }
1457 };
1458
1459 let shared_thread = match SharedThread::from_bytes(&thread_data) {
1460 Ok(thread) => thread,
1461 Err(_) => {
1462 Self::show_deferred_toast(
1463 &self.workspace,
1464 "Failed to parse thread data from clipboard",
1465 cx,
1466 );
1467 return;
1468 }
1469 };
1470
1471 let db_thread = shared_thread.to_db_thread();
1472 let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1473 let thread_store = self.thread_store.clone();
1474 let title = db_thread.title.clone();
1475 let workspace = self.workspace.clone();
1476
1477 cx.spawn_in(window, async move |this, cx| {
1478 thread_store
1479 .update(&mut cx.clone(), |store, cx| {
1480 store.save_thread(session_id.clone(), db_thread, cx)
1481 })
1482 .await?;
1483
1484 let thread_metadata = acp_thread::AgentSessionInfo {
1485 session_id,
1486 cwd: None,
1487 title: Some(title),
1488 updated_at: Some(chrono::Utc::now()),
1489 meta: None,
1490 };
1491
1492 this.update_in(cx, |this, window, cx| {
1493 this.open_thread(thread_metadata, window, cx);
1494 })?;
1495
1496 this.update_in(cx, |_, _window, cx| {
1497 if let Some(workspace) = workspace.upgrade() {
1498 workspace.update(cx, |workspace, cx| {
1499 struct ThreadLoadedToast;
1500 workspace.show_toast(
1501 workspace::Toast::new(
1502 workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1503 "Thread loaded from clipboard",
1504 )
1505 .autohide(),
1506 cx,
1507 );
1508 });
1509 }
1510 })?;
1511
1512 anyhow::Ok(())
1513 })
1514 .detach_and_log_err(cx);
1515 }
1516
1517 fn handle_agent_configuration_event(
1518 &mut self,
1519 _entity: &Entity<AgentConfiguration>,
1520 event: &AssistantConfigurationEvent,
1521 window: &mut Window,
1522 cx: &mut Context<Self>,
1523 ) {
1524 match event {
1525 AssistantConfigurationEvent::NewThread(provider) => {
1526 if LanguageModelRegistry::read_global(cx)
1527 .default_model()
1528 .is_none_or(|model| model.provider.id() != provider.id())
1529 && let Some(model) = provider.default_model(cx)
1530 {
1531 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1532 let provider = model.provider_id().0.to_string();
1533 let enable_thinking = model.supports_thinking();
1534 let effort = model
1535 .default_effort_level()
1536 .map(|effort| effort.value.to_string());
1537 let model = model.id().0.to_string();
1538 settings
1539 .agent
1540 .get_or_insert_default()
1541 .set_model(LanguageModelSelection {
1542 provider: LanguageModelProviderSetting(provider),
1543 model,
1544 enable_thinking,
1545 effort,
1546 })
1547 });
1548 }
1549
1550 self.new_thread(&NewThread, window, cx);
1551 if let Some((thread, model)) = self
1552 .active_native_agent_thread(cx)
1553 .zip(provider.default_model(cx))
1554 {
1555 thread.update(cx, |thread, cx| {
1556 thread.set_model(model, cx);
1557 });
1558 }
1559 }
1560 }
1561 }
1562
1563 pub fn as_active_server_view(&self) -> Option<&Entity<AcpServerView>> {
1564 match &self.active_view {
1565 ActiveView::AgentThread { server_view } => Some(server_view),
1566 _ => None,
1567 }
1568 }
1569
1570 pub fn as_active_thread_view(&self, cx: &App) -> Option<Entity<AcpThreadView>> {
1571 let server_view = self.as_active_server_view()?;
1572 server_view.read(cx).active_thread().cloned()
1573 }
1574
1575 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1576 match &self.active_view {
1577 ActiveView::AgentThread { server_view, .. } => server_view
1578 .read(cx)
1579 .active_thread()
1580 .map(|r| r.read(cx).thread.clone()),
1581 _ => None,
1582 }
1583 }
1584
1585 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
1586 match &self.active_view {
1587 ActiveView::AgentThread { server_view, .. } => {
1588 server_view.read(cx).as_native_thread(cx)
1589 }
1590 _ => None,
1591 }
1592 }
1593
1594 pub(crate) fn active_text_thread_editor(&self) -> Option<Entity<TextThreadEditor>> {
1595 match &self.active_view {
1596 ActiveView::TextThread {
1597 text_thread_editor, ..
1598 } => Some(text_thread_editor.clone()),
1599 _ => None,
1600 }
1601 }
1602
1603 fn set_active_view(
1604 &mut self,
1605 new_view: ActiveView,
1606 focus: bool,
1607 window: &mut Window,
1608 cx: &mut Context<Self>,
1609 ) {
1610 let was_in_agent_history = matches!(
1611 self.active_view,
1612 ActiveView::History {
1613 kind: HistoryKind::AgentThreads
1614 }
1615 );
1616 let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
1617 let current_is_history = matches!(self.active_view, ActiveView::History { .. });
1618 let new_is_history = matches!(new_view, ActiveView::History { .. });
1619
1620 let current_is_config = matches!(self.active_view, ActiveView::Configuration);
1621 let new_is_config = matches!(new_view, ActiveView::Configuration);
1622
1623 let current_is_special = current_is_history || current_is_config;
1624 let new_is_special = new_is_history || new_is_config;
1625
1626 if current_is_uninitialized || (current_is_special && !new_is_special) {
1627 self.active_view = new_view;
1628 } else if !current_is_special && new_is_special {
1629 self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
1630 } else {
1631 if !new_is_special {
1632 self.previous_view = None;
1633 }
1634 self.active_view = new_view;
1635 }
1636
1637 self._active_view_observation = match &self.active_view {
1638 ActiveView::AgentThread { server_view } => {
1639 Some(cx.observe(server_view, |this, _, cx| {
1640 cx.emit(AgentPanelEvent::ActiveViewChanged);
1641 this.serialize(cx);
1642 cx.notify();
1643 }))
1644 }
1645 _ => None,
1646 };
1647
1648 let is_in_agent_history = matches!(
1649 self.active_view,
1650 ActiveView::History {
1651 kind: HistoryKind::AgentThreads
1652 }
1653 );
1654
1655 if !was_in_agent_history && is_in_agent_history {
1656 self.acp_history
1657 .update(cx, |history, cx| history.refresh_full_history(cx));
1658 }
1659
1660 if focus {
1661 self.focus_handle(cx).focus(window, cx);
1662 }
1663 cx.emit(AgentPanelEvent::ActiveViewChanged);
1664 }
1665
1666 fn populate_recently_updated_menu_section(
1667 mut menu: ContextMenu,
1668 panel: Entity<Self>,
1669 kind: HistoryKind,
1670 cx: &mut Context<ContextMenu>,
1671 ) -> ContextMenu {
1672 match kind {
1673 HistoryKind::AgentThreads => {
1674 let entries = panel
1675 .read(cx)
1676 .acp_history
1677 .read(cx)
1678 .sessions()
1679 .iter()
1680 .take(RECENTLY_UPDATED_MENU_LIMIT)
1681 .cloned()
1682 .collect::<Vec<_>>();
1683
1684 if entries.is_empty() {
1685 return menu;
1686 }
1687
1688 menu = menu.header("Recently Updated");
1689
1690 for entry in entries {
1691 let title = entry
1692 .title
1693 .as_ref()
1694 .filter(|title| !title.is_empty())
1695 .cloned()
1696 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
1697
1698 menu = menu.entry(title, None, {
1699 let panel = panel.downgrade();
1700 let entry = entry.clone();
1701 move |window, cx| {
1702 let entry = entry.clone();
1703 panel
1704 .update(cx, move |this, cx| {
1705 this.load_agent_thread(entry.clone(), window, cx);
1706 })
1707 .ok();
1708 }
1709 });
1710 }
1711 }
1712 HistoryKind::TextThreads => {
1713 let entries = panel
1714 .read(cx)
1715 .text_thread_store
1716 .read(cx)
1717 .ordered_text_threads()
1718 .take(RECENTLY_UPDATED_MENU_LIMIT)
1719 .cloned()
1720 .collect::<Vec<_>>();
1721
1722 if entries.is_empty() {
1723 return menu;
1724 }
1725
1726 menu = menu.header("Recent Text Threads");
1727
1728 for entry in entries {
1729 let title = if entry.title.is_empty() {
1730 SharedString::new_static(DEFAULT_THREAD_TITLE)
1731 } else {
1732 entry.title.clone()
1733 };
1734
1735 menu = menu.entry(title, None, {
1736 let panel = panel.downgrade();
1737 let entry = entry.clone();
1738 move |window, cx| {
1739 let path = entry.path.clone();
1740 panel
1741 .update(cx, move |this, cx| {
1742 this.open_saved_text_thread(path.clone(), window, cx)
1743 .detach_and_log_err(cx);
1744 })
1745 .ok();
1746 }
1747 });
1748 }
1749 }
1750 }
1751
1752 menu.separator()
1753 }
1754
1755 pub fn selected_agent(&self) -> AgentType {
1756 self.selected_agent.clone()
1757 }
1758
1759 fn selected_external_agent(&self) -> Option<ExternalAgent> {
1760 match &self.selected_agent {
1761 AgentType::NativeAgent => Some(ExternalAgent::NativeAgent),
1762 AgentType::Gemini => Some(ExternalAgent::Gemini),
1763 AgentType::ClaudeAgent => Some(ExternalAgent::ClaudeCode),
1764 AgentType::Codex => Some(ExternalAgent::Codex),
1765 AgentType::Custom { name } => Some(ExternalAgent::Custom { name: name.clone() }),
1766 AgentType::TextThread => None,
1767 }
1768 }
1769
1770 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
1771 if let Some(extension_store) = ExtensionStore::try_global(cx) {
1772 let (manifests, extensions_dir) = {
1773 let store = extension_store.read(cx);
1774 let installed = store.installed_extensions();
1775 let manifests: Vec<_> = installed
1776 .iter()
1777 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
1778 .collect();
1779 let extensions_dir = paths::extensions_dir().join("installed");
1780 (manifests, extensions_dir)
1781 };
1782
1783 self.project.update(cx, |project, cx| {
1784 project.agent_server_store().update(cx, |store, cx| {
1785 let manifest_refs: Vec<_> = manifests
1786 .iter()
1787 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
1788 .collect();
1789 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
1790 });
1791 });
1792 }
1793 }
1794
1795 pub fn new_external_thread_with_text(
1796 &mut self,
1797 initial_text: Option<String>,
1798 window: &mut Window,
1799 cx: &mut Context<Self>,
1800 ) {
1801 self.external_thread(
1802 None,
1803 None,
1804 initial_text.map(|text| AgentInitialContent::ContentBlock {
1805 blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
1806 auto_submit: false,
1807 }),
1808 window,
1809 cx,
1810 );
1811 }
1812
1813 pub fn new_agent_thread(
1814 &mut self,
1815 agent: AgentType,
1816 window: &mut Window,
1817 cx: &mut Context<Self>,
1818 ) {
1819 match agent {
1820 AgentType::TextThread => {
1821 window.dispatch_action(NewTextThread.boxed_clone(), cx);
1822 }
1823 AgentType::NativeAgent => self.external_thread(
1824 Some(crate::ExternalAgent::NativeAgent),
1825 None,
1826 None,
1827 window,
1828 cx,
1829 ),
1830 AgentType::Gemini => {
1831 self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx)
1832 }
1833 AgentType::ClaudeAgent => {
1834 self.selected_agent = AgentType::ClaudeAgent;
1835 self.serialize(cx);
1836 self.external_thread(
1837 Some(crate::ExternalAgent::ClaudeCode),
1838 None,
1839 None,
1840 window,
1841 cx,
1842 )
1843 }
1844 AgentType::Codex => {
1845 self.selected_agent = AgentType::Codex;
1846 self.serialize(cx);
1847 self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx)
1848 }
1849 AgentType::Custom { name } => self.external_thread(
1850 Some(crate::ExternalAgent::Custom { name }),
1851 None,
1852 None,
1853 window,
1854 cx,
1855 ),
1856 }
1857 }
1858
1859 pub fn load_agent_thread(
1860 &mut self,
1861 thread: AgentSessionInfo,
1862 window: &mut Window,
1863 cx: &mut Context<Self>,
1864 ) {
1865 let Some(agent) = self.selected_external_agent() else {
1866 return;
1867 };
1868 self.external_thread(Some(agent), Some(thread), None, window, cx);
1869 }
1870
1871 fn _external_thread(
1872 &mut self,
1873 server: Rc<dyn AgentServer>,
1874 resume_thread: Option<AgentSessionInfo>,
1875 initial_content: Option<AgentInitialContent>,
1876 workspace: WeakEntity<Workspace>,
1877 project: Entity<Project>,
1878 ext_agent: ExternalAgent,
1879 window: &mut Window,
1880 cx: &mut Context<Self>,
1881 ) {
1882 let selected_agent = AgentType::from(ext_agent);
1883 if self.selected_agent != selected_agent {
1884 self.selected_agent = selected_agent;
1885 self.serialize(cx);
1886 }
1887 let thread_store = server
1888 .clone()
1889 .downcast::<agent::NativeAgentServer>()
1890 .is_some()
1891 .then(|| self.thread_store.clone());
1892
1893 let server_view = cx.new(|cx| {
1894 crate::acp::AcpServerView::new(
1895 server,
1896 resume_thread,
1897 initial_content,
1898 workspace.clone(),
1899 project,
1900 thread_store,
1901 self.prompt_store.clone(),
1902 self.acp_history.clone(),
1903 window,
1904 cx,
1905 )
1906 });
1907
1908 self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx);
1909 }
1910}
1911
1912impl Focusable for AgentPanel {
1913 fn focus_handle(&self, cx: &App) -> FocusHandle {
1914 match &self.active_view {
1915 ActiveView::Uninitialized => self.focus_handle.clone(),
1916 ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
1917 ActiveView::History { kind } => match kind {
1918 HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
1919 HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
1920 },
1921 ActiveView::TextThread {
1922 text_thread_editor, ..
1923 } => text_thread_editor.focus_handle(cx),
1924 ActiveView::Configuration => {
1925 if let Some(configuration) = self.configuration.as_ref() {
1926 configuration.focus_handle(cx)
1927 } else {
1928 self.focus_handle.clone()
1929 }
1930 }
1931 }
1932 }
1933}
1934
1935fn agent_panel_dock_position(cx: &App) -> DockPosition {
1936 AgentSettings::get_global(cx).dock.into()
1937}
1938
1939pub enum AgentPanelEvent {
1940 ActiveViewChanged,
1941}
1942
1943impl EventEmitter<PanelEvent> for AgentPanel {}
1944impl EventEmitter<AgentPanelEvent> for AgentPanel {}
1945
1946impl Panel for AgentPanel {
1947 fn persistent_name() -> &'static str {
1948 "AgentPanel"
1949 }
1950
1951 fn panel_key() -> &'static str {
1952 AGENT_PANEL_KEY
1953 }
1954
1955 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
1956 agent_panel_dock_position(cx)
1957 }
1958
1959 fn position_is_valid(&self, position: DockPosition) -> bool {
1960 position != DockPosition::Bottom
1961 }
1962
1963 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1964 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
1965 settings
1966 .agent
1967 .get_or_insert_default()
1968 .set_dock(position.into());
1969 });
1970 }
1971
1972 fn size(&self, window: &Window, cx: &App) -> Pixels {
1973 let settings = AgentSettings::get_global(cx);
1974 match self.position(window, cx) {
1975 DockPosition::Left | DockPosition::Right => {
1976 self.width.unwrap_or(settings.default_width)
1977 }
1978 DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
1979 }
1980 }
1981
1982 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
1983 match self.position(window, cx) {
1984 DockPosition::Left | DockPosition::Right => self.width = size,
1985 DockPosition::Bottom => self.height = size,
1986 }
1987 self.serialize(cx);
1988 cx.notify();
1989 }
1990
1991 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
1992 if active && matches!(self.active_view, ActiveView::Uninitialized) {
1993 let selected_agent = self.selected_agent.clone();
1994 self.new_agent_thread(selected_agent, window, cx);
1995 }
1996 }
1997
1998 fn remote_id() -> Option<proto::PanelId> {
1999 Some(proto::PanelId::AssistantPanel)
2000 }
2001
2002 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
2003 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
2004 }
2005
2006 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2007 Some("Agent Panel")
2008 }
2009
2010 fn toggle_action(&self) -> Box<dyn Action> {
2011 Box::new(ToggleFocus)
2012 }
2013
2014 fn activation_priority(&self) -> u32 {
2015 3
2016 }
2017
2018 fn enabled(&self, cx: &App) -> bool {
2019 AgentSettings::get_global(cx).enabled(cx)
2020 }
2021
2022 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
2023 self.zoomed
2024 }
2025
2026 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
2027 self.zoomed = zoomed;
2028 cx.notify();
2029 }
2030}
2031
2032impl AgentPanel {
2033 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
2034 const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
2035
2036 let content = match &self.active_view {
2037 ActiveView::AgentThread { server_view } => {
2038 let is_generating_title = server_view
2039 .read(cx)
2040 .as_native_thread(cx)
2041 .map_or(false, |t| t.read(cx).is_generating_title());
2042
2043 if let Some(title_editor) = server_view
2044 .read(cx)
2045 .parent_thread(cx)
2046 .map(|r| r.read(cx).title_editor.clone())
2047 {
2048 let container = div()
2049 .w_full()
2050 .on_action({
2051 let thread_view = server_view.downgrade();
2052 move |_: &menu::Confirm, window, cx| {
2053 if let Some(thread_view) = thread_view.upgrade() {
2054 thread_view.focus_handle(cx).focus(window, cx);
2055 }
2056 }
2057 })
2058 .on_action({
2059 let thread_view = server_view.downgrade();
2060 move |_: &editor::actions::Cancel, window, cx| {
2061 if let Some(thread_view) = thread_view.upgrade() {
2062 thread_view.focus_handle(cx).focus(window, cx);
2063 }
2064 }
2065 })
2066 .child(title_editor);
2067
2068 if is_generating_title {
2069 container
2070 .with_animation(
2071 "generating_title",
2072 Animation::new(Duration::from_secs(2))
2073 .repeat()
2074 .with_easing(pulsating_between(0.4, 0.8)),
2075 |div, delta| div.opacity(delta),
2076 )
2077 .into_any_element()
2078 } else {
2079 container.into_any_element()
2080 }
2081 } else {
2082 Label::new(server_view.read(cx).title(cx))
2083 .color(Color::Muted)
2084 .truncate()
2085 .into_any_element()
2086 }
2087 }
2088 ActiveView::TextThread {
2089 title_editor,
2090 text_thread_editor,
2091 ..
2092 } => {
2093 let summary = text_thread_editor.read(cx).text_thread().read(cx).summary();
2094
2095 match summary {
2096 TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT)
2097 .color(Color::Muted)
2098 .truncate()
2099 .into_any_element(),
2100 TextThreadSummary::Content(summary) => {
2101 if summary.done {
2102 div()
2103 .w_full()
2104 .child(title_editor.clone())
2105 .into_any_element()
2106 } else {
2107 Label::new(LOADING_SUMMARY_PLACEHOLDER)
2108 .truncate()
2109 .color(Color::Muted)
2110 .with_animation(
2111 "generating_title",
2112 Animation::new(Duration::from_secs(2))
2113 .repeat()
2114 .with_easing(pulsating_between(0.4, 0.8)),
2115 |label, delta| label.alpha(delta),
2116 )
2117 .into_any_element()
2118 }
2119 }
2120 TextThreadSummary::Error => h_flex()
2121 .w_full()
2122 .child(title_editor.clone())
2123 .child(
2124 IconButton::new("retry-summary-generation", IconName::RotateCcw)
2125 .icon_size(IconSize::Small)
2126 .on_click({
2127 let text_thread_editor = text_thread_editor.clone();
2128 move |_, _window, cx| {
2129 text_thread_editor.update(cx, |text_thread_editor, cx| {
2130 text_thread_editor.regenerate_summary(cx);
2131 });
2132 }
2133 })
2134 .tooltip(move |_window, cx| {
2135 cx.new(|_| {
2136 Tooltip::new("Failed to generate title")
2137 .meta("Click to try again")
2138 })
2139 .into()
2140 }),
2141 )
2142 .into_any_element(),
2143 }
2144 }
2145 ActiveView::History { kind } => {
2146 let title = match kind {
2147 HistoryKind::AgentThreads => "History",
2148 HistoryKind::TextThreads => "Text Thread History",
2149 };
2150 Label::new(title).truncate().into_any_element()
2151 }
2152 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
2153 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
2154 };
2155
2156 h_flex()
2157 .key_context("TitleEditor")
2158 .id("TitleEditor")
2159 .flex_grow()
2160 .w_full()
2161 .max_w_full()
2162 .overflow_x_scroll()
2163 .child(content)
2164 .into_any()
2165 }
2166
2167 fn handle_regenerate_thread_title(thread_view: Entity<AcpServerView>, cx: &mut App) {
2168 thread_view.update(cx, |thread_view, cx| {
2169 if let Some(thread) = thread_view.as_native_thread(cx) {
2170 thread.update(cx, |thread, cx| {
2171 thread.generate_title(cx);
2172 });
2173 }
2174 });
2175 }
2176
2177 fn handle_regenerate_text_thread_title(
2178 text_thread_editor: Entity<TextThreadEditor>,
2179 cx: &mut App,
2180 ) {
2181 text_thread_editor.update(cx, |text_thread_editor, cx| {
2182 text_thread_editor.regenerate_summary(cx);
2183 });
2184 }
2185
2186 fn render_panel_options_menu(
2187 &self,
2188 window: &mut Window,
2189 cx: &mut Context<Self>,
2190 ) -> impl IntoElement {
2191 let focus_handle = self.focus_handle(cx);
2192
2193 let full_screen_label = if self.is_zoomed(window, cx) {
2194 "Disable Full Screen"
2195 } else {
2196 "Enable Full Screen"
2197 };
2198
2199 let selected_agent = self.selected_agent.clone();
2200
2201 let text_thread_view = match &self.active_view {
2202 ActiveView::TextThread {
2203 text_thread_editor, ..
2204 } => Some(text_thread_editor.clone()),
2205 _ => None,
2206 };
2207 let text_thread_with_messages = match &self.active_view {
2208 ActiveView::TextThread {
2209 text_thread_editor, ..
2210 } => text_thread_editor
2211 .read(cx)
2212 .text_thread()
2213 .read(cx)
2214 .messages(cx)
2215 .any(|message| message.role == language_model::Role::Assistant),
2216 _ => false,
2217 };
2218
2219 let thread_view = match &self.active_view {
2220 ActiveView::AgentThread { server_view } => Some(server_view.clone()),
2221 _ => None,
2222 };
2223 let thread_with_messages = match &self.active_view {
2224 ActiveView::AgentThread { server_view } => {
2225 server_view.read(cx).has_user_submitted_prompt(cx)
2226 }
2227 _ => false,
2228 };
2229
2230 PopoverMenu::new("agent-options-menu")
2231 .trigger_with_tooltip(
2232 IconButton::new("agent-options-menu", IconName::Ellipsis)
2233 .icon_size(IconSize::Small),
2234 {
2235 let focus_handle = focus_handle.clone();
2236 move |_window, cx| {
2237 Tooltip::for_action_in(
2238 "Toggle Agent Menu",
2239 &ToggleOptionsMenu,
2240 &focus_handle,
2241 cx,
2242 )
2243 }
2244 },
2245 )
2246 .anchor(Corner::TopRight)
2247 .with_handle(self.agent_panel_menu_handle.clone())
2248 .menu({
2249 move |window, cx| {
2250 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
2251 menu = menu.context(focus_handle.clone());
2252
2253 if thread_with_messages | text_thread_with_messages {
2254 menu = menu.header("Current Thread");
2255
2256 if let Some(text_thread_view) = text_thread_view.as_ref() {
2257 menu = menu
2258 .entry("Regenerate Thread Title", None, {
2259 let text_thread_view = text_thread_view.clone();
2260 move |_, cx| {
2261 Self::handle_regenerate_text_thread_title(
2262 text_thread_view.clone(),
2263 cx,
2264 );
2265 }
2266 })
2267 .separator();
2268 }
2269
2270 if let Some(thread_view) = thread_view.as_ref() {
2271 menu = menu
2272 .entry("Regenerate Thread Title", None, {
2273 let thread_view = thread_view.clone();
2274 move |_, cx| {
2275 Self::handle_regenerate_thread_title(
2276 thread_view.clone(),
2277 cx,
2278 );
2279 }
2280 })
2281 .separator();
2282 }
2283 }
2284
2285 menu = menu
2286 .header("MCP Servers")
2287 .action(
2288 "View Server Extensions",
2289 Box::new(zed_actions::Extensions {
2290 category_filter: Some(
2291 zed_actions::ExtensionCategoryFilter::ContextServers,
2292 ),
2293 id: None,
2294 }),
2295 )
2296 .action("Add Custom Server…", Box::new(AddContextServer))
2297 .separator()
2298 .action("Rules", Box::new(OpenRulesLibrary::default()))
2299 .action("Profiles", Box::new(ManageProfiles::default()))
2300 .action("Settings", Box::new(OpenSettings))
2301 .separator()
2302 .action(full_screen_label, Box::new(ToggleZoom));
2303
2304 if selected_agent == AgentType::Gemini {
2305 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
2306 }
2307
2308 menu
2309 }))
2310 }
2311 })
2312 }
2313
2314 fn render_recent_entries_menu(
2315 &self,
2316 icon: IconName,
2317 corner: Corner,
2318 cx: &mut Context<Self>,
2319 ) -> impl IntoElement {
2320 let focus_handle = self.focus_handle(cx);
2321
2322 PopoverMenu::new("agent-nav-menu")
2323 .trigger_with_tooltip(
2324 IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
2325 {
2326 move |_window, cx| {
2327 Tooltip::for_action_in(
2328 "Toggle Recently Updated Threads",
2329 &ToggleNavigationMenu,
2330 &focus_handle,
2331 cx,
2332 )
2333 }
2334 },
2335 )
2336 .anchor(corner)
2337 .with_handle(self.agent_navigation_menu_handle.clone())
2338 .menu({
2339 let menu = self.agent_navigation_menu.clone();
2340 move |window, cx| {
2341 telemetry::event!("View Thread History Clicked");
2342
2343 if let Some(menu) = menu.as_ref() {
2344 menu.update(cx, |_, cx| {
2345 cx.defer_in(window, |menu, window, cx| {
2346 menu.rebuild(window, cx);
2347 });
2348 })
2349 }
2350 menu.clone()
2351 }
2352 })
2353 }
2354
2355 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2356 let focus_handle = self.focus_handle(cx);
2357
2358 IconButton::new("go-back", IconName::ArrowLeft)
2359 .icon_size(IconSize::Small)
2360 .on_click(cx.listener(|this, _, window, cx| {
2361 this.go_back(&workspace::GoBack, window, cx);
2362 }))
2363 .tooltip({
2364 move |_window, cx| {
2365 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
2366 }
2367 })
2368 }
2369
2370 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2371 let agent_server_store = self.project.read(cx).agent_server_store().clone();
2372 let focus_handle = self.focus_handle(cx);
2373
2374 let (selected_agent_custom_icon, selected_agent_label) =
2375 if let AgentType::Custom { name, .. } = &self.selected_agent {
2376 let store = agent_server_store.read(cx);
2377 let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
2378
2379 let label = store
2380 .agent_display_name(&ExternalAgentServerName(name.clone()))
2381 .unwrap_or_else(|| self.selected_agent.label());
2382 (icon, label)
2383 } else {
2384 (None, self.selected_agent.label())
2385 };
2386
2387 let active_thread = match &self.active_view {
2388 ActiveView::AgentThread { server_view } => server_view.read(cx).as_native_thread(cx),
2389 ActiveView::Uninitialized
2390 | ActiveView::TextThread { .. }
2391 | ActiveView::History { .. }
2392 | ActiveView::Configuration => None,
2393 };
2394
2395 let new_thread_menu = PopoverMenu::new("new_thread_menu")
2396 .trigger_with_tooltip(
2397 IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
2398 {
2399 let focus_handle = focus_handle.clone();
2400 move |_window, cx| {
2401 Tooltip::for_action_in(
2402 "New Thread…",
2403 &ToggleNewThreadMenu,
2404 &focus_handle,
2405 cx,
2406 )
2407 }
2408 },
2409 )
2410 .anchor(Corner::TopRight)
2411 .with_handle(self.new_thread_menu_handle.clone())
2412 .menu({
2413 let selected_agent = self.selected_agent.clone();
2414 let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type;
2415
2416 let workspace = self.workspace.clone();
2417 let is_via_collab = workspace
2418 .update(cx, |workspace, cx| {
2419 workspace.project().read(cx).is_via_collab()
2420 })
2421 .unwrap_or_default();
2422
2423 move |window, cx| {
2424 telemetry::event!("New Thread Clicked");
2425
2426 let active_thread = active_thread.clone();
2427 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
2428 menu.context(focus_handle.clone())
2429 .when_some(active_thread, |this, active_thread| {
2430 let thread = active_thread.read(cx);
2431
2432 if !thread.is_empty() {
2433 let session_id = thread.id().clone();
2434 this.item(
2435 ContextMenuEntry::new("New From Summary")
2436 .icon(IconName::ThreadFromSummary)
2437 .icon_color(Color::Muted)
2438 .handler(move |window, cx| {
2439 window.dispatch_action(
2440 Box::new(NewNativeAgentThreadFromSummary {
2441 from_session_id: session_id.clone(),
2442 }),
2443 cx,
2444 );
2445 }),
2446 )
2447 } else {
2448 this
2449 }
2450 })
2451 .item(
2452 ContextMenuEntry::new("Zed Agent")
2453 .when(
2454 is_agent_selected(AgentType::NativeAgent)
2455 | is_agent_selected(AgentType::TextThread),
2456 |this| {
2457 this.action(Box::new(NewExternalAgentThread {
2458 agent: None,
2459 }))
2460 },
2461 )
2462 .icon(IconName::ZedAgent)
2463 .icon_color(Color::Muted)
2464 .handler({
2465 let workspace = workspace.clone();
2466 move |window, cx| {
2467 if let Some(workspace) = workspace.upgrade() {
2468 workspace.update(cx, |workspace, cx| {
2469 if let Some(panel) =
2470 workspace.panel::<AgentPanel>(cx)
2471 {
2472 panel.update(cx, |panel, cx| {
2473 panel.new_agent_thread(
2474 AgentType::NativeAgent,
2475 window,
2476 cx,
2477 );
2478 });
2479 }
2480 });
2481 }
2482 }
2483 }),
2484 )
2485 .item(
2486 ContextMenuEntry::new("Text Thread")
2487 .action(NewTextThread.boxed_clone())
2488 .icon(IconName::TextThread)
2489 .icon_color(Color::Muted)
2490 .handler({
2491 let workspace = workspace.clone();
2492 move |window, cx| {
2493 if let Some(workspace) = workspace.upgrade() {
2494 workspace.update(cx, |workspace, cx| {
2495 if let Some(panel) =
2496 workspace.panel::<AgentPanel>(cx)
2497 {
2498 panel.update(cx, |panel, cx| {
2499 panel.new_agent_thread(
2500 AgentType::TextThread,
2501 window,
2502 cx,
2503 );
2504 });
2505 }
2506 });
2507 }
2508 }
2509 }),
2510 )
2511 .separator()
2512 .header("External Agents")
2513 .item(
2514 ContextMenuEntry::new("Claude Agent")
2515 .when(is_agent_selected(AgentType::ClaudeAgent), |this| {
2516 this.action(Box::new(NewExternalAgentThread {
2517 agent: None,
2518 }))
2519 })
2520 .icon(IconName::AiClaude)
2521 .disabled(is_via_collab)
2522 .icon_color(Color::Muted)
2523 .handler({
2524 let workspace = workspace.clone();
2525 move |window, cx| {
2526 if let Some(workspace) = workspace.upgrade() {
2527 workspace.update(cx, |workspace, cx| {
2528 if let Some(panel) =
2529 workspace.panel::<AgentPanel>(cx)
2530 {
2531 panel.update(cx, |panel, cx| {
2532 panel.new_agent_thread(
2533 AgentType::ClaudeAgent,
2534 window,
2535 cx,
2536 );
2537 });
2538 }
2539 });
2540 }
2541 }
2542 }),
2543 )
2544 .item(
2545 ContextMenuEntry::new("Codex CLI")
2546 .when(is_agent_selected(AgentType::Codex), |this| {
2547 this.action(Box::new(NewExternalAgentThread {
2548 agent: None,
2549 }))
2550 })
2551 .icon(IconName::AiOpenAi)
2552 .disabled(is_via_collab)
2553 .icon_color(Color::Muted)
2554 .handler({
2555 let workspace = workspace.clone();
2556 move |window, cx| {
2557 if let Some(workspace) = workspace.upgrade() {
2558 workspace.update(cx, |workspace, cx| {
2559 if let Some(panel) =
2560 workspace.panel::<AgentPanel>(cx)
2561 {
2562 panel.update(cx, |panel, cx| {
2563 panel.new_agent_thread(
2564 AgentType::Codex,
2565 window,
2566 cx,
2567 );
2568 });
2569 }
2570 });
2571 }
2572 }
2573 }),
2574 )
2575 .item(
2576 ContextMenuEntry::new("Gemini CLI")
2577 .when(is_agent_selected(AgentType::Gemini), |this| {
2578 this.action(Box::new(NewExternalAgentThread {
2579 agent: None,
2580 }))
2581 })
2582 .icon(IconName::AiGemini)
2583 .icon_color(Color::Muted)
2584 .disabled(is_via_collab)
2585 .handler({
2586 let workspace = workspace.clone();
2587 move |window, cx| {
2588 if let Some(workspace) = workspace.upgrade() {
2589 workspace.update(cx, |workspace, cx| {
2590 if let Some(panel) =
2591 workspace.panel::<AgentPanel>(cx)
2592 {
2593 panel.update(cx, |panel, cx| {
2594 panel.new_agent_thread(
2595 AgentType::Gemini,
2596 window,
2597 cx,
2598 );
2599 });
2600 }
2601 });
2602 }
2603 }
2604 }),
2605 )
2606 .map(|mut menu| {
2607 let agent_server_store = agent_server_store.read(cx);
2608 let agent_names = agent_server_store
2609 .external_agents()
2610 .filter(|name| {
2611 name.0 != GEMINI_NAME
2612 && name.0 != CLAUDE_AGENT_NAME
2613 && name.0 != CODEX_NAME
2614 })
2615 .cloned()
2616 .collect::<Vec<_>>();
2617
2618 for agent_name in agent_names {
2619 let icon_path = agent_server_store.agent_icon(&agent_name);
2620 let display_name = agent_server_store
2621 .agent_display_name(&agent_name)
2622 .unwrap_or_else(|| agent_name.0.clone());
2623
2624 let mut entry = ContextMenuEntry::new(display_name);
2625
2626 if let Some(icon_path) = icon_path {
2627 entry = entry.custom_icon_svg(icon_path);
2628 } else {
2629 entry = entry.icon(IconName::Sparkle);
2630 }
2631 entry = entry
2632 .when(
2633 is_agent_selected(AgentType::Custom {
2634 name: agent_name.0.clone(),
2635 }),
2636 |this| {
2637 this.action(Box::new(NewExternalAgentThread {
2638 agent: None,
2639 }))
2640 },
2641 )
2642 .icon_color(Color::Muted)
2643 .disabled(is_via_collab)
2644 .handler({
2645 let workspace = workspace.clone();
2646 let agent_name = agent_name.clone();
2647 move |window, cx| {
2648 if let Some(workspace) = workspace.upgrade() {
2649 workspace.update(cx, |workspace, cx| {
2650 if let Some(panel) =
2651 workspace.panel::<AgentPanel>(cx)
2652 {
2653 panel.update(cx, |panel, cx| {
2654 panel.new_agent_thread(
2655 AgentType::Custom {
2656 name: agent_name
2657 .clone()
2658 .into(),
2659 },
2660 window,
2661 cx,
2662 );
2663 });
2664 }
2665 });
2666 }
2667 }
2668 });
2669
2670 menu = menu.item(entry);
2671 }
2672
2673 menu
2674 })
2675 .separator()
2676 .item(
2677 ContextMenuEntry::new("Add More Agents")
2678 .icon(IconName::Plus)
2679 .icon_color(Color::Muted)
2680 .handler({
2681 move |window, cx| {
2682 window.dispatch_action(
2683 Box::new(zed_actions::AcpRegistry),
2684 cx,
2685 )
2686 }
2687 }),
2688 )
2689 }))
2690 }
2691 });
2692
2693 let is_thread_loading = self
2694 .active_thread_view()
2695 .map(|thread| thread.read(cx).is_loading())
2696 .unwrap_or(false);
2697
2698 let has_custom_icon = selected_agent_custom_icon.is_some();
2699
2700 let selected_agent = div()
2701 .id("selected_agent_icon")
2702 .when_some(selected_agent_custom_icon, |this, icon_path| {
2703 this.px_1()
2704 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
2705 })
2706 .when(!has_custom_icon, |this| {
2707 this.when_some(self.selected_agent.icon(), |this, icon| {
2708 this.px_1().child(Icon::new(icon).color(Color::Muted))
2709 })
2710 })
2711 .tooltip(move |_, cx| {
2712 Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx)
2713 });
2714
2715 let selected_agent = if is_thread_loading {
2716 selected_agent
2717 .with_animation(
2718 "pulsating-icon",
2719 Animation::new(Duration::from_secs(1))
2720 .repeat()
2721 .with_easing(pulsating_between(0.2, 0.6)),
2722 |icon, delta| icon.opacity(delta),
2723 )
2724 .into_any_element()
2725 } else {
2726 selected_agent.into_any_element()
2727 };
2728
2729 let show_history_menu = self.history_kind_for_selected_agent(cx).is_some();
2730
2731 h_flex()
2732 .id("agent-panel-toolbar")
2733 .h(Tab::container_height(cx))
2734 .max_w_full()
2735 .flex_none()
2736 .justify_between()
2737 .gap_2()
2738 .bg(cx.theme().colors().tab_bar_background)
2739 .border_b_1()
2740 .border_color(cx.theme().colors().border)
2741 .child(
2742 h_flex()
2743 .size_full()
2744 .gap(DynamicSpacing::Base04.rems(cx))
2745 .pl(DynamicSpacing::Base04.rems(cx))
2746 .child(match &self.active_view {
2747 ActiveView::History { .. } | ActiveView::Configuration => {
2748 self.render_toolbar_back_button(cx).into_any_element()
2749 }
2750 _ => selected_agent.into_any_element(),
2751 })
2752 .child(self.render_title_view(window, cx)),
2753 )
2754 .child(
2755 h_flex()
2756 .flex_none()
2757 .gap(DynamicSpacing::Base02.rems(cx))
2758 .pl(DynamicSpacing::Base04.rems(cx))
2759 .pr(DynamicSpacing::Base06.rems(cx))
2760 .child(new_thread_menu)
2761 .when(show_history_menu, |this| {
2762 this.child(self.render_recent_entries_menu(
2763 IconName::MenuAltTemp,
2764 Corner::TopRight,
2765 cx,
2766 ))
2767 })
2768 .child(self.render_panel_options_menu(window, cx)),
2769 )
2770 }
2771
2772 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
2773 if TrialEndUpsell::dismissed() {
2774 return false;
2775 }
2776
2777 match &self.active_view {
2778 ActiveView::TextThread { .. } => {
2779 if LanguageModelRegistry::global(cx)
2780 .read(cx)
2781 .default_model()
2782 .is_some_and(|model| {
2783 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
2784 })
2785 {
2786 return false;
2787 }
2788 }
2789 ActiveView::Uninitialized
2790 | ActiveView::AgentThread { .. }
2791 | ActiveView::History { .. }
2792 | ActiveView::Configuration => return false,
2793 }
2794
2795 let plan = self.user_store.read(cx).plan();
2796 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
2797
2798 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
2799 }
2800
2801 fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
2802 if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
2803 return false;
2804 }
2805
2806 let user_store = self.user_store.read(cx);
2807
2808 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
2809 && user_store
2810 .subscription_period()
2811 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
2812 .is_some_and(|date| date < chrono::Utc::now())
2813 {
2814 OnboardingUpsell::set_dismissed(true, cx);
2815 self.on_boarding_upsell_dismissed
2816 .store(true, Ordering::Release);
2817 return false;
2818 }
2819
2820 match &self.active_view {
2821 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
2822 false
2823 }
2824 ActiveView::AgentThread { server_view, .. }
2825 if server_view.read(cx).as_native_thread(cx).is_none() =>
2826 {
2827 false
2828 }
2829 _ => {
2830 let history_is_empty = self.acp_history.read(cx).is_empty();
2831
2832 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
2833 .visible_providers()
2834 .iter()
2835 .any(|provider| {
2836 provider.is_authenticated(cx)
2837 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
2838 });
2839
2840 history_is_empty || !has_configured_non_zed_providers
2841 }
2842 }
2843 }
2844
2845 fn render_onboarding(
2846 &self,
2847 _window: &mut Window,
2848 cx: &mut Context<Self>,
2849 ) -> Option<impl IntoElement> {
2850 if !self.should_render_onboarding(cx) {
2851 return None;
2852 }
2853
2854 let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. });
2855
2856 Some(
2857 div()
2858 .when(text_thread_view, |this| {
2859 this.bg(cx.theme().colors().editor_background)
2860 })
2861 .child(self.onboarding.clone()),
2862 )
2863 }
2864
2865 fn render_trial_end_upsell(
2866 &self,
2867 _window: &mut Window,
2868 cx: &mut Context<Self>,
2869 ) -> Option<impl IntoElement> {
2870 if !self.should_render_trial_end_upsell(cx) {
2871 return None;
2872 }
2873
2874 Some(
2875 v_flex()
2876 .absolute()
2877 .inset_0()
2878 .size_full()
2879 .bg(cx.theme().colors().panel_background)
2880 .opacity(0.85)
2881 .block_mouse_except_scroll()
2882 .child(EndTrialUpsell::new(Arc::new({
2883 let this = cx.entity();
2884 move |_, cx| {
2885 this.update(cx, |_this, cx| {
2886 TrialEndUpsell::set_dismissed(true, cx);
2887 cx.notify();
2888 });
2889 }
2890 }))),
2891 )
2892 }
2893
2894 fn emit_configuration_error_telemetry_if_needed(
2895 &mut self,
2896 configuration_error: Option<&ConfigurationError>,
2897 ) {
2898 let error_kind = configuration_error.map(|err| match err {
2899 ConfigurationError::NoProvider => "no_provider",
2900 ConfigurationError::ModelNotFound => "model_not_found",
2901 ConfigurationError::ProviderNotAuthenticated(_) => "provider_not_authenticated",
2902 });
2903
2904 let error_kind_string = error_kind.map(String::from);
2905
2906 if self.last_configuration_error_telemetry == error_kind_string {
2907 return;
2908 }
2909
2910 self.last_configuration_error_telemetry = error_kind_string;
2911
2912 if let Some(kind) = error_kind {
2913 let message = configuration_error
2914 .map(|err| err.to_string())
2915 .unwrap_or_default();
2916
2917 telemetry::event!("Agent Panel Error Shown", kind = kind, message = message,);
2918 }
2919 }
2920
2921 fn render_configuration_error(
2922 &self,
2923 border_bottom: bool,
2924 configuration_error: &ConfigurationError,
2925 focus_handle: &FocusHandle,
2926 cx: &mut App,
2927 ) -> impl IntoElement {
2928 let zed_provider_configured = AgentSettings::get_global(cx)
2929 .default_model
2930 .as_ref()
2931 .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev");
2932
2933 let callout = if zed_provider_configured {
2934 Callout::new()
2935 .icon(IconName::Warning)
2936 .severity(Severity::Warning)
2937 .when(border_bottom, |this| {
2938 this.border_position(ui::BorderPosition::Bottom)
2939 })
2940 .title("Sign in to continue using Zed as your LLM provider.")
2941 .actions_slot(
2942 Button::new("sign_in", "Sign In")
2943 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
2944 .label_size(LabelSize::Small)
2945 .on_click({
2946 let workspace = self.workspace.clone();
2947 move |_, _, cx| {
2948 let Ok(client) =
2949 workspace.update(cx, |workspace, _| workspace.client().clone())
2950 else {
2951 return;
2952 };
2953
2954 cx.spawn(async move |cx| {
2955 client.sign_in_with_optional_connect(true, cx).await
2956 })
2957 .detach_and_log_err(cx);
2958 }
2959 }),
2960 )
2961 } else {
2962 Callout::new()
2963 .icon(IconName::Warning)
2964 .severity(Severity::Warning)
2965 .when(border_bottom, |this| {
2966 this.border_position(ui::BorderPosition::Bottom)
2967 })
2968 .title(configuration_error.to_string())
2969 .actions_slot(
2970 Button::new("settings", "Configure")
2971 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
2972 .label_size(LabelSize::Small)
2973 .key_binding(
2974 KeyBinding::for_action_in(&OpenSettings, focus_handle, cx)
2975 .map(|kb| kb.size(rems_from_px(12.))),
2976 )
2977 .on_click(|_event, window, cx| {
2978 window.dispatch_action(OpenSettings.boxed_clone(), cx)
2979 }),
2980 )
2981 };
2982
2983 match configuration_error {
2984 ConfigurationError::ModelNotFound
2985 | ConfigurationError::ProviderNotAuthenticated(_)
2986 | ConfigurationError::NoProvider => callout.into_any_element(),
2987 }
2988 }
2989
2990 fn render_text_thread(
2991 &self,
2992 text_thread_editor: &Entity<TextThreadEditor>,
2993 buffer_search_bar: &Entity<BufferSearchBar>,
2994 window: &mut Window,
2995 cx: &mut Context<Self>,
2996 ) -> Div {
2997 let mut registrar = buffer_search::DivRegistrar::new(
2998 |this, _, _cx| match &this.active_view {
2999 ActiveView::TextThread {
3000 buffer_search_bar, ..
3001 } => Some(buffer_search_bar.clone()),
3002 _ => None,
3003 },
3004 cx,
3005 );
3006 BufferSearchBar::register(&mut registrar);
3007 registrar
3008 .into_div()
3009 .size_full()
3010 .relative()
3011 .map(|parent| {
3012 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
3013 if buffer_search_bar.is_dismissed() {
3014 return parent;
3015 }
3016 parent.child(
3017 div()
3018 .p(DynamicSpacing::Base08.rems(cx))
3019 .border_b_1()
3020 .border_color(cx.theme().colors().border_variant)
3021 .bg(cx.theme().colors().editor_background)
3022 .child(buffer_search_bar.render(window, cx)),
3023 )
3024 })
3025 })
3026 .child(text_thread_editor.clone())
3027 .child(self.render_drag_target(cx))
3028 }
3029
3030 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
3031 let is_local = self.project.read(cx).is_local();
3032 div()
3033 .invisible()
3034 .absolute()
3035 .top_0()
3036 .right_0()
3037 .bottom_0()
3038 .left_0()
3039 .bg(cx.theme().colors().drop_target_background)
3040 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
3041 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
3042 .when(is_local, |this| {
3043 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
3044 })
3045 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
3046 let item = tab.pane.read(cx).item_for_index(tab.ix);
3047 let project_paths = item
3048 .and_then(|item| item.project_path(cx))
3049 .into_iter()
3050 .collect::<Vec<_>>();
3051 this.handle_drop(project_paths, vec![], window, cx);
3052 }))
3053 .on_drop(
3054 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
3055 let project_paths = selection
3056 .items()
3057 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
3058 .collect::<Vec<_>>();
3059 this.handle_drop(project_paths, vec![], window, cx);
3060 }),
3061 )
3062 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
3063 let tasks = paths
3064 .paths()
3065 .iter()
3066 .map(|path| {
3067 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
3068 })
3069 .collect::<Vec<_>>();
3070 cx.spawn_in(window, async move |this, cx| {
3071 let mut paths = vec![];
3072 let mut added_worktrees = vec![];
3073 let opened_paths = futures::future::join_all(tasks).await;
3074 for entry in opened_paths {
3075 if let Some((worktree, project_path)) = entry.log_err() {
3076 added_worktrees.push(worktree);
3077 paths.push(project_path);
3078 }
3079 }
3080 this.update_in(cx, |this, window, cx| {
3081 this.handle_drop(paths, added_worktrees, window, cx);
3082 })
3083 .ok();
3084 })
3085 .detach();
3086 }))
3087 }
3088
3089 fn handle_drop(
3090 &mut self,
3091 paths: Vec<ProjectPath>,
3092 added_worktrees: Vec<Entity<Worktree>>,
3093 window: &mut Window,
3094 cx: &mut Context<Self>,
3095 ) {
3096 match &self.active_view {
3097 ActiveView::AgentThread { server_view } => {
3098 server_view.update(cx, |thread_view, cx| {
3099 thread_view.insert_dragged_files(paths, added_worktrees, window, cx);
3100 });
3101 }
3102 ActiveView::TextThread {
3103 text_thread_editor, ..
3104 } => {
3105 text_thread_editor.update(cx, |text_thread_editor, cx| {
3106 TextThreadEditor::insert_dragged_files(
3107 text_thread_editor,
3108 paths,
3109 added_worktrees,
3110 window,
3111 cx,
3112 );
3113 });
3114 }
3115 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3116 }
3117 }
3118
3119 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
3120 if !self.show_trust_workspace_message {
3121 return None;
3122 }
3123
3124 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
3125
3126 Some(
3127 Callout::new()
3128 .icon(IconName::Warning)
3129 .severity(Severity::Warning)
3130 .border_position(ui::BorderPosition::Bottom)
3131 .title("You're in Restricted Mode")
3132 .description(description)
3133 .actions_slot(
3134 Button::new("open-trust-modal", "Configure Project Trust")
3135 .label_size(LabelSize::Small)
3136 .style(ButtonStyle::Outlined)
3137 .on_click({
3138 cx.listener(move |this, _, window, cx| {
3139 this.workspace
3140 .update(cx, |workspace, cx| {
3141 workspace
3142 .show_worktree_trust_security_modal(true, window, cx)
3143 })
3144 .log_err();
3145 })
3146 }),
3147 ),
3148 )
3149 }
3150
3151 fn key_context(&self) -> KeyContext {
3152 let mut key_context = KeyContext::new_with_defaults();
3153 key_context.add("AgentPanel");
3154 match &self.active_view {
3155 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
3156 ActiveView::TextThread { .. } => key_context.add("text_thread"),
3157 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3158 }
3159 key_context
3160 }
3161}
3162
3163impl Render for AgentPanel {
3164 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3165 // WARNING: Changes to this element hierarchy can have
3166 // non-obvious implications to the layout of children.
3167 //
3168 // If you need to change it, please confirm:
3169 // - The message editor expands (cmd-option-esc) correctly
3170 // - When expanded, the buttons at the bottom of the panel are displayed correctly
3171 // - Font size works as expected and can be changed with cmd-+/cmd-
3172 // - Scrolling in all views works as expected
3173 // - Files can be dropped into the panel
3174 let content = v_flex()
3175 .relative()
3176 .size_full()
3177 .justify_between()
3178 .key_context(self.key_context())
3179 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
3180 this.new_thread(action, window, cx);
3181 }))
3182 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
3183 this.open_history(window, cx);
3184 }))
3185 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
3186 this.open_configuration(window, cx);
3187 }))
3188 .on_action(cx.listener(Self::open_active_thread_as_markdown))
3189 .on_action(cx.listener(Self::deploy_rules_library))
3190 .on_action(cx.listener(Self::go_back))
3191 .on_action(cx.listener(Self::toggle_navigation_menu))
3192 .on_action(cx.listener(Self::toggle_options_menu))
3193 .on_action(cx.listener(Self::increase_font_size))
3194 .on_action(cx.listener(Self::decrease_font_size))
3195 .on_action(cx.listener(Self::reset_font_size))
3196 .on_action(cx.listener(Self::toggle_zoom))
3197 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
3198 if let Some(thread_view) = this.active_thread_view() {
3199 thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx))
3200 }
3201 }))
3202 .child(self.render_toolbar(window, cx))
3203 .children(self.render_workspace_trust_message(cx))
3204 .children(self.render_onboarding(window, cx))
3205 .map(|parent| {
3206 // Emit configuration error telemetry before entering the match to avoid borrow conflicts
3207 if matches!(&self.active_view, ActiveView::TextThread { .. }) {
3208 let model_registry = LanguageModelRegistry::read_global(cx);
3209 let configuration_error =
3210 model_registry.configuration_error(model_registry.default_model(), cx);
3211 self.emit_configuration_error_telemetry_if_needed(configuration_error.as_ref());
3212 }
3213
3214 match &self.active_view {
3215 ActiveView::Uninitialized => parent,
3216 ActiveView::AgentThread { server_view, .. } => parent
3217 .child(server_view.clone())
3218 .child(self.render_drag_target(cx)),
3219 ActiveView::History { kind } => match kind {
3220 HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
3221 HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
3222 },
3223 ActiveView::TextThread {
3224 text_thread_editor,
3225 buffer_search_bar,
3226 ..
3227 } => {
3228 let model_registry = LanguageModelRegistry::read_global(cx);
3229 let configuration_error =
3230 model_registry.configuration_error(model_registry.default_model(), cx);
3231
3232 parent
3233 .map(|this| {
3234 if !self.should_render_onboarding(cx)
3235 && let Some(err) = configuration_error.as_ref()
3236 {
3237 this.child(self.render_configuration_error(
3238 true,
3239 err,
3240 &self.focus_handle(cx),
3241 cx,
3242 ))
3243 } else {
3244 this
3245 }
3246 })
3247 .child(self.render_text_thread(
3248 text_thread_editor,
3249 buffer_search_bar,
3250 window,
3251 cx,
3252 ))
3253 }
3254 ActiveView::Configuration => parent.children(self.configuration.clone()),
3255 }
3256 })
3257 .children(self.render_trial_end_upsell(window, cx));
3258
3259 match self.active_view.which_font_size_used() {
3260 WhichFontSize::AgentFont => {
3261 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
3262 .size_full()
3263 .child(content)
3264 .into_any()
3265 }
3266 _ => content.into_any(),
3267 }
3268 }
3269}
3270
3271struct PromptLibraryInlineAssist {
3272 workspace: WeakEntity<Workspace>,
3273}
3274
3275impl PromptLibraryInlineAssist {
3276 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
3277 Self { workspace }
3278 }
3279}
3280
3281impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
3282 fn assist(
3283 &self,
3284 prompt_editor: &Entity<Editor>,
3285 initial_prompt: Option<String>,
3286 window: &mut Window,
3287 cx: &mut Context<RulesLibrary>,
3288 ) {
3289 InlineAssistant::update_global(cx, |assistant, cx| {
3290 let Some(workspace) = self.workspace.upgrade() else {
3291 return;
3292 };
3293 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3294 return;
3295 };
3296 let project = workspace.read(cx).project().downgrade();
3297 let panel = panel.read(cx);
3298 let thread_store = panel.thread_store().clone();
3299 let history = panel.history().downgrade();
3300 assistant.assist(
3301 prompt_editor,
3302 self.workspace.clone(),
3303 project,
3304 thread_store,
3305 None,
3306 history,
3307 initial_prompt,
3308 window,
3309 cx,
3310 );
3311 })
3312 }
3313
3314 fn focus_agent_panel(
3315 &self,
3316 workspace: &mut Workspace,
3317 window: &mut Window,
3318 cx: &mut Context<Workspace>,
3319 ) -> bool {
3320 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
3321 }
3322}
3323
3324pub struct ConcreteAssistantPanelDelegate;
3325
3326impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
3327 fn active_text_thread_editor(
3328 &self,
3329 workspace: &mut Workspace,
3330 _window: &mut Window,
3331 cx: &mut Context<Workspace>,
3332 ) -> Option<Entity<TextThreadEditor>> {
3333 let panel = workspace.panel::<AgentPanel>(cx)?;
3334 panel.read(cx).active_text_thread_editor()
3335 }
3336
3337 fn open_local_text_thread(
3338 &self,
3339 workspace: &mut Workspace,
3340 path: Arc<Path>,
3341 window: &mut Window,
3342 cx: &mut Context<Workspace>,
3343 ) -> Task<Result<()>> {
3344 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
3345 return Task::ready(Err(anyhow!("Agent panel not found")));
3346 };
3347
3348 panel.update(cx, |panel, cx| {
3349 panel.open_saved_text_thread(path, window, cx)
3350 })
3351 }
3352
3353 fn open_remote_text_thread(
3354 &self,
3355 _workspace: &mut Workspace,
3356 _text_thread_id: assistant_text_thread::TextThreadId,
3357 _window: &mut Window,
3358 _cx: &mut Context<Workspace>,
3359 ) -> Task<Result<Entity<TextThreadEditor>>> {
3360 Task::ready(Err(anyhow!("opening remote context not implemented")))
3361 }
3362
3363 fn quote_selection(
3364 &self,
3365 workspace: &mut Workspace,
3366 selection_ranges: Vec<Range<Anchor>>,
3367 buffer: Entity<MultiBuffer>,
3368 window: &mut Window,
3369 cx: &mut Context<Workspace>,
3370 ) {
3371 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
3372 return;
3373 };
3374
3375 if !panel.focus_handle(cx).contains_focused(window, cx) {
3376 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
3377 }
3378
3379 panel.update(cx, |_, cx| {
3380 // Wait to create a new context until the workspace is no longer
3381 // being updated.
3382 cx.defer_in(window, move |panel, window, cx| {
3383 if let Some(thread_view) = panel.active_thread_view() {
3384 thread_view.update(cx, |thread_view, cx| {
3385 thread_view.insert_selections(window, cx);
3386 });
3387 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
3388 let snapshot = buffer.read(cx).snapshot(cx);
3389 let selection_ranges = selection_ranges
3390 .into_iter()
3391 .map(|range| range.to_point(&snapshot))
3392 .collect::<Vec<_>>();
3393
3394 text_thread_editor.update(cx, |text_thread_editor, cx| {
3395 text_thread_editor.quote_ranges(selection_ranges, snapshot, window, cx)
3396 });
3397 }
3398 });
3399 });
3400 }
3401
3402 fn quote_terminal_text(
3403 &self,
3404 workspace: &mut Workspace,
3405 text: String,
3406 window: &mut Window,
3407 cx: &mut Context<Workspace>,
3408 ) {
3409 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
3410 return;
3411 };
3412
3413 if !panel.focus_handle(cx).contains_focused(window, cx) {
3414 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
3415 }
3416
3417 panel.update(cx, |_, cx| {
3418 // Wait to create a new context until the workspace is no longer
3419 // being updated.
3420 cx.defer_in(window, move |panel, window, cx| {
3421 if let Some(thread_view) = panel.active_thread_view() {
3422 thread_view.update(cx, |thread_view, cx| {
3423 thread_view.insert_terminal_text(text, window, cx);
3424 });
3425 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
3426 text_thread_editor.update(cx, |text_thread_editor, cx| {
3427 text_thread_editor.quote_terminal_text(text, window, cx)
3428 });
3429 }
3430 });
3431 });
3432 }
3433}
3434
3435struct OnboardingUpsell;
3436
3437impl Dismissable for OnboardingUpsell {
3438 const KEY: &'static str = "dismissed-trial-upsell";
3439}
3440
3441struct TrialEndUpsell;
3442
3443impl Dismissable for TrialEndUpsell {
3444 const KEY: &'static str = "dismissed-trial-end-upsell";
3445}
3446
3447/// Test-only helper methods
3448#[cfg(any(test, feature = "test-support"))]
3449impl AgentPanel {
3450 /// Opens an external thread using an arbitrary AgentServer.
3451 ///
3452 /// This is a test-only helper that allows visual tests and integration tests
3453 /// to inject a stub server without modifying production code paths.
3454 /// Not compiled into production builds.
3455 pub fn open_external_thread_with_server(
3456 &mut self,
3457 server: Rc<dyn AgentServer>,
3458 window: &mut Window,
3459 cx: &mut Context<Self>,
3460 ) {
3461 let workspace = self.workspace.clone();
3462 let project = self.project.clone();
3463
3464 let ext_agent = ExternalAgent::Custom {
3465 name: server.name(),
3466 };
3467
3468 self._external_thread(
3469 server, None, None, workspace, project, ext_agent, window, cx,
3470 );
3471 }
3472
3473 /// Returns the currently active thread view, if any.
3474 ///
3475 /// This is a test-only accessor that exposes the private `active_thread_view()`
3476 /// method for test assertions. Not compiled into production builds.
3477 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<AcpServerView>> {
3478 self.active_thread_view()
3479 }
3480}
3481
3482#[cfg(test)]
3483mod tests {
3484 use super::*;
3485 use crate::acp::thread_view::tests::{StubAgentServer, init_test};
3486 use assistant_text_thread::TextThreadStore;
3487 use feature_flags::FeatureFlagAppExt;
3488 use fs::FakeFs;
3489 use gpui::{TestAppContext, VisualTestContext};
3490 use project::Project;
3491 use workspace::MultiWorkspace;
3492
3493 #[gpui::test]
3494 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
3495 init_test(cx);
3496 cx.update(|cx| {
3497 cx.update_flags(true, vec!["agent-v2".to_string()]);
3498 agent::ThreadStore::init_global(cx);
3499 language_model::LanguageModelRegistry::test(cx);
3500 });
3501
3502 // --- Create a MultiWorkspace window with two workspaces ---
3503 let fs = FakeFs::new(cx.executor());
3504 let project_a = Project::test(fs.clone(), [], cx).await;
3505 let project_b = Project::test(fs, [], cx).await;
3506
3507 let multi_workspace =
3508 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3509
3510 let workspace_a = multi_workspace
3511 .read_with(cx, |multi_workspace, _cx| {
3512 multi_workspace.workspace().clone()
3513 })
3514 .unwrap();
3515
3516 let workspace_b = multi_workspace
3517 .update(cx, |multi_workspace, window, cx| {
3518 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
3519 })
3520 .unwrap();
3521
3522 workspace_a.update(cx, |workspace, _cx| {
3523 workspace.set_random_database_id();
3524 });
3525 workspace_b.update(cx, |workspace, _cx| {
3526 workspace.set_random_database_id();
3527 });
3528
3529 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
3530
3531 // --- Set up workspace A: width=300, with an active thread ---
3532 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
3533 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx));
3534 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
3535 });
3536
3537 panel_a.update(cx, |panel, _cx| {
3538 panel.width = Some(px(300.0));
3539 });
3540
3541 panel_a.update_in(cx, |panel, window, cx| {
3542 panel.open_external_thread_with_server(
3543 Rc::new(StubAgentServer::default_response()),
3544 window,
3545 cx,
3546 );
3547 });
3548
3549 cx.run_until_parked();
3550
3551 panel_a.read_with(cx, |panel, cx| {
3552 assert!(
3553 panel.active_agent_thread(cx).is_some(),
3554 "workspace A should have an active thread after connection"
3555 );
3556 });
3557
3558 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
3559
3560 // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
3561 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
3562 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx));
3563 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
3564 });
3565
3566 panel_b.update(cx, |panel, _cx| {
3567 panel.width = Some(px(400.0));
3568 panel.selected_agent = AgentType::ClaudeAgent;
3569 });
3570
3571 // --- Serialize both panels ---
3572 panel_a.update(cx, |panel, cx| panel.serialize(cx));
3573 panel_b.update(cx, |panel, cx| panel.serialize(cx));
3574 cx.run_until_parked();
3575
3576 // --- Load fresh panels for each workspace and verify independent state ---
3577 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
3578
3579 let async_cx = cx.update(|window, cx| window.to_async(cx));
3580 let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx)
3581 .await
3582 .expect("panel A load should succeed");
3583 cx.run_until_parked();
3584
3585 let async_cx = cx.update(|window, cx| window.to_async(cx));
3586 let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx)
3587 .await
3588 .expect("panel B load should succeed");
3589 cx.run_until_parked();
3590
3591 // Workspace A should restore width and agent type, but the thread
3592 // should NOT be restored because the stub agent never persisted it
3593 // to the database (the load-side validation skips missing threads).
3594 loaded_a.read_with(cx, |panel, _cx| {
3595 assert_eq!(
3596 panel.width,
3597 Some(px(300.0)),
3598 "workspace A width should be restored"
3599 );
3600 assert_eq!(
3601 panel.selected_agent, agent_type_a,
3602 "workspace A agent type should be restored"
3603 );
3604 });
3605
3606 // Workspace B should restore its own width and agent type, with no thread
3607 loaded_b.read_with(cx, |panel, _cx| {
3608 assert_eq!(
3609 panel.width,
3610 Some(px(400.0)),
3611 "workspace B width should be restored"
3612 );
3613 assert_eq!(
3614 panel.selected_agent,
3615 AgentType::ClaudeAgent,
3616 "workspace B agent type should be restored"
3617 );
3618 assert!(
3619 panel.active_thread_view().is_none(),
3620 "workspace B should have no active thread"
3621 );
3622 });
3623 }
3624
3625 // Simple regression test
3626 #[gpui::test]
3627 async fn test_new_text_thread_action_handler(cx: &mut TestAppContext) {
3628 init_test(cx);
3629
3630 let fs = FakeFs::new(cx.executor());
3631
3632 cx.update(|cx| {
3633 cx.update_flags(true, vec!["agent-v2".to_string()]);
3634 agent::ThreadStore::init_global(cx);
3635 language_model::LanguageModelRegistry::test(cx);
3636 let slash_command_registry =
3637 assistant_slash_command::SlashCommandRegistry::default_global(cx);
3638 slash_command_registry
3639 .register_command(assistant_slash_commands::DefaultSlashCommand, false);
3640 <dyn fs::Fs>::set_global(fs.clone(), cx);
3641 });
3642
3643 let project = Project::test(fs.clone(), [], cx).await;
3644
3645 let multi_workspace =
3646 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3647
3648 let workspace_a = multi_workspace
3649 .read_with(cx, |multi_workspace, _cx| {
3650 multi_workspace.workspace().clone()
3651 })
3652 .unwrap();
3653
3654 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
3655
3656 workspace_a.update_in(cx, |workspace, window, cx| {
3657 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
3658 let panel =
3659 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
3660 workspace.add_panel(panel, window, cx);
3661 });
3662
3663 cx.run_until_parked();
3664
3665 workspace_a.update_in(cx, |_, window, cx| {
3666 window.dispatch_action(NewTextThread.boxed_clone(), cx);
3667 });
3668
3669 cx.run_until_parked();
3670 }
3671}