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