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