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