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