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