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