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