1use std::{
2 ops::Range,
3 path::{Path, PathBuf},
4 rc::Rc,
5 sync::{
6 Arc,
7 atomic::{AtomicBool, Ordering},
8 },
9 time::Duration,
10};
11
12use acp_thread::{AcpThread, 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 feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
26use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff};
27
28use crate::ManageProfiles;
29use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
30use crate::{
31 AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
32 InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown,
33 OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
34 ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, ToggleStartThreadInSelector,
35 agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
36 connection_view::{AcpThreadViewEvent, ThreadView},
37 slash_command::SlashCommandCompletionProvider,
38 text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate},
39 ui::EndTrialUpsell,
40};
41use crate::{
42 AgentInitialContent, ExternalAgent, ExternalSourcePrompt, NewExternalAgentThread,
43 NewNativeAgentThreadFromSummary,
44};
45use crate::{
46 ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
47 text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
48};
49use agent_settings::AgentSettings;
50use ai_onboarding::AgentPanelOnboarding;
51use anyhow::{Result, anyhow};
52use assistant_slash_command::SlashCommandWorkingSet;
53use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
54use client::UserStore;
55use cloud_api_types::Plan;
56use collections::HashMap;
57use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
58use extension::ExtensionEvents;
59use extension_host::ExtensionStore;
60use fs::Fs;
61use git::repository::validate_worktree_directory;
62use gpui::{
63 Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
64 DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
65 Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
66};
67use language::LanguageRegistry;
68use language_model::{ConfigurationError, LanguageModelRegistry};
69use project::project_settings::ProjectSettings;
70use project::{Project, ProjectPath, Worktree};
71use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
72use rules_library::{RulesLibrary, open_rules_library};
73use search::{BufferSearchBar, buffer_search};
74use settings::{Settings, update_settings_file};
75use theme::ThemeSettings;
76use ui::{
77 Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding,
78 PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
79 utils::WithRemSize,
80};
81use util::ResultExt as _;
82use workspace::{
83 CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
84 WorkspaceId,
85 dock::{DockPosition, Panel, PanelEvent},
86};
87use zed_actions::{
88 DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
89 agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
90 assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
91};
92
93const AGENT_PANEL_KEY: &str = "agent_panel";
94const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
95const DEFAULT_THREAD_TITLE: &str = "New Thread";
96
97fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
98 let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
99 let key = i64::from(workspace_id).to_string();
100 scope
101 .read(&key)
102 .log_err()
103 .flatten()
104 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
105}
106
107async fn save_serialized_panel(
108 workspace_id: workspace::WorkspaceId,
109 panel: SerializedAgentPanel,
110) -> Result<()> {
111 let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
112 let key = i64::from(workspace_id).to_string();
113 scope.write(key, serde_json::to_string(&panel)?).await?;
114 Ok(())
115}
116
117/// Migration: reads the original single-panel format stored under the
118/// `"agent_panel"` KVP key before per-workspace keying was introduced.
119fn read_legacy_serialized_panel() -> Option<SerializedAgentPanel> {
120 KEY_VALUE_STORE
121 .read_kvp(AGENT_PANEL_KEY)
122 .log_err()
123 .flatten()
124 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
125}
126
127#[derive(Serialize, Deserialize, Debug, Clone)]
128struct SerializedAgentPanel {
129 width: Option<Pixels>,
130 selected_agent: Option<AgentType>,
131 #[serde(default)]
132 last_active_thread: Option<SerializedActiveThread>,
133 #[serde(default)]
134 start_thread_in: Option<StartThreadIn>,
135}
136
137#[derive(Serialize, Deserialize, Debug, Clone)]
138struct SerializedActiveThread {
139 session_id: String,
140 agent_type: AgentType,
141 title: Option<String>,
142 cwd: Option<std::path::PathBuf>,
143}
144
145pub fn init(cx: &mut App) {
146 cx.observe_new(
147 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
148 workspace
149 .register_action(|workspace, action: &NewThread, window, cx| {
150 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
151 panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
152 workspace.focus_panel::<AgentPanel>(window, cx);
153 }
154 })
155 .register_action(
156 |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
157 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
158 panel.update(cx, |panel, cx| {
159 panel.new_native_agent_thread_from_summary(action, window, cx)
160 });
161 workspace.focus_panel::<AgentPanel>(window, cx);
162 }
163 },
164 )
165 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
166 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
167 workspace.focus_panel::<AgentPanel>(window, cx);
168 panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
169 }
170 })
171 .register_action(|workspace, _: &OpenHistory, window, cx| {
172 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
173 workspace.focus_panel::<AgentPanel>(window, cx);
174 panel.update(cx, |panel, cx| panel.open_history(window, cx));
175 }
176 })
177 .register_action(|workspace, _: &OpenSettings, window, cx| {
178 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
179 workspace.focus_panel::<AgentPanel>(window, cx);
180 panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
181 }
182 })
183 .register_action(|workspace, _: &NewTextThread, 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.new_text_thread(window, cx);
188 });
189 }
190 })
191 .register_action(|workspace, action: &NewExternalAgentThread, 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.external_thread(
196 action.agent.clone(),
197 None,
198 None,
199 None,
200 None,
201 true,
202 window,
203 cx,
204 )
205 });
206 }
207 })
208 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
209 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
210 workspace.focus_panel::<AgentPanel>(window, cx);
211 panel.update(cx, |panel, cx| {
212 panel.deploy_rules_library(action, window, cx)
213 });
214 }
215 })
216 .register_action(|workspace, _: &Follow, window, cx| {
217 workspace.follow(CollaboratorId::Agent, window, cx);
218 })
219 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
220 let thread = workspace
221 .panel::<AgentPanel>(cx)
222 .and_then(|panel| panel.read(cx).active_connection_view().cloned())
223 .and_then(|thread_view| {
224 thread_view
225 .read(cx)
226 .active_thread()
227 .map(|r| r.read(cx).thread.clone())
228 });
229
230 if let Some(thread) = thread {
231 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
232 }
233 })
234 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
235 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
236 workspace.focus_panel::<AgentPanel>(window, cx);
237 panel.update(cx, |panel, cx| {
238 panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
239 });
240 }
241 })
242 .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
243 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
244 workspace.focus_panel::<AgentPanel>(window, cx);
245 panel.update(cx, |panel, cx| {
246 panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
247 });
248 }
249 })
250 .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
251 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
252 workspace.focus_panel::<AgentPanel>(window, cx);
253 panel.update(cx, |panel, cx| {
254 panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
255 });
256 }
257 })
258 .register_action(|workspace, _: &ToggleStartThreadInSelector, window, cx| {
259 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
260 workspace.focus_panel::<AgentPanel>(window, cx);
261 panel.update(cx, |panel, cx| {
262 panel.toggle_start_thread_in_selector(
263 &ToggleStartThreadInSelector,
264 window,
265 cx,
266 );
267 });
268 }
269 })
270 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
271 AcpOnboardingModal::toggle(workspace, window, cx)
272 })
273 .register_action(
274 |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| {
275 ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
276 },
277 )
278 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
279 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
280 window.refresh();
281 })
282 .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
283 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
284 panel.update(cx, |panel, _| {
285 panel
286 .on_boarding_upsell_dismissed
287 .store(false, Ordering::Release);
288 });
289 }
290 OnboardingUpsell::set_dismissed(false, cx);
291 })
292 .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
293 TrialEndUpsell::set_dismissed(false, cx);
294 })
295 .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
296 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
297 panel.update(cx, |panel, cx| {
298 panel.reset_agent_zoom(window, cx);
299 });
300 }
301 })
302 .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
303 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
304 panel.update(cx, |panel, cx| {
305 panel.copy_thread_to_clipboard(window, cx);
306 });
307 }
308 })
309 .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
310 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
311 workspace.focus_panel::<AgentPanel>(window, cx);
312 panel.update(cx, |panel, cx| {
313 panel.load_thread_from_clipboard(window, cx);
314 });
315 }
316 })
317 .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
318 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
319 return;
320 };
321
322 let mention_uri = MentionUri::GitDiff {
323 base_ref: action.base_ref.to_string(),
324 };
325 let diff_uri = mention_uri.to_uri().to_string();
326
327 let content_blocks = vec![
328 acp::ContentBlock::Text(acp::TextContent::new(
329 "Please review this branch diff carefully. Point out any issues, \
330 potential bugs, or improvement opportunities you find.\n\n"
331 .to_string(),
332 )),
333 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
334 acp::EmbeddedResourceResource::TextResourceContents(
335 acp::TextResourceContents::new(
336 action.diff_text.to_string(),
337 diff_uri,
338 ),
339 ),
340 )),
341 ];
342
343 workspace.focus_panel::<AgentPanel>(window, cx);
344
345 panel.update(cx, |panel, cx| {
346 panel.external_thread(
347 None,
348 None,
349 None,
350 None,
351 Some(AgentInitialContent::ContentBlock {
352 blocks: content_blocks,
353 auto_submit: true,
354 }),
355 true,
356 window,
357 cx,
358 );
359 });
360 })
361 .register_action(|workspace, action: &StartThreadIn, _window, cx| {
362 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
363 panel.update(cx, |panel, cx| {
364 panel.set_start_thread_in(action, cx);
365 });
366 }
367 });
368 },
369 )
370 .detach();
371}
372
373#[derive(Clone, Copy, Debug, PartialEq, Eq)]
374enum HistoryKind {
375 AgentThreads,
376 TextThreads,
377}
378
379enum ActiveView {
380 Uninitialized,
381 AgentThread {
382 server_view: Entity<ConnectionView>,
383 },
384 TextThread {
385 text_thread_editor: Entity<TextThreadEditor>,
386 title_editor: Entity<Editor>,
387 buffer_search_bar: Entity<BufferSearchBar>,
388 _subscriptions: Vec<gpui::Subscription>,
389 },
390 History {
391 kind: HistoryKind,
392 },
393 Configuration,
394}
395
396enum WhichFontSize {
397 AgentFont,
398 BufferFont,
399 None,
400}
401
402// TODO unify this with ExternalAgent
403#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
404pub enum AgentType {
405 #[default]
406 NativeAgent,
407 TextThread,
408 Custom {
409 name: SharedString,
410 },
411}
412
413impl AgentType {
414 pub fn is_native(&self) -> bool {
415 matches!(self, Self::NativeAgent)
416 }
417
418 fn label(&self) -> SharedString {
419 match self {
420 Self::NativeAgent | Self::TextThread => "Zed Agent".into(),
421 Self::Custom { name, .. } => name.into(),
422 }
423 }
424
425 fn icon(&self) -> Option<IconName> {
426 match self {
427 Self::NativeAgent | Self::TextThread => None,
428 Self::Custom { .. } => Some(IconName::Sparkle),
429 }
430 }
431}
432
433impl From<ExternalAgent> for AgentType {
434 fn from(value: ExternalAgent) -> Self {
435 match value {
436 ExternalAgent::Custom { name } => Self::Custom { name },
437 ExternalAgent::NativeAgent => Self::NativeAgent,
438 }
439 }
440}
441
442impl StartThreadIn {
443 fn label(&self) -> SharedString {
444 match self {
445 Self::LocalProject => "Current Project".into(),
446 Self::NewWorktree => "New Worktree".into(),
447 }
448 }
449}
450
451#[derive(Clone, Debug)]
452#[allow(dead_code)]
453pub enum WorktreeCreationStatus {
454 Creating,
455 Error(SharedString),
456}
457
458impl ActiveView {
459 pub fn which_font_size_used(&self) -> WhichFontSize {
460 match self {
461 ActiveView::Uninitialized
462 | ActiveView::AgentThread { .. }
463 | ActiveView::History { .. } => WhichFontSize::AgentFont,
464 ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
465 ActiveView::Configuration => WhichFontSize::None,
466 }
467 }
468
469 pub fn text_thread(
470 text_thread_editor: Entity<TextThreadEditor>,
471 language_registry: Arc<LanguageRegistry>,
472 window: &mut Window,
473 cx: &mut App,
474 ) -> Self {
475 let title = text_thread_editor.read(cx).title(cx).to_string();
476
477 let editor = cx.new(|cx| {
478 let mut editor = Editor::single_line(window, cx);
479 editor.set_text(title, window, cx);
480 editor
481 });
482
483 // This is a workaround for `editor.set_text` emitting a `BufferEdited` event, which would
484 // cause a custom summary to be set. The presence of this custom summary would cause
485 // summarization to not happen.
486 let mut suppress_first_edit = true;
487
488 let subscriptions = vec![
489 window.subscribe(&editor, cx, {
490 {
491 let text_thread_editor = text_thread_editor.clone();
492 move |editor, event, window, cx| match event {
493 EditorEvent::BufferEdited => {
494 if suppress_first_edit {
495 suppress_first_edit = false;
496 return;
497 }
498 let new_summary = editor.read(cx).text(cx);
499
500 text_thread_editor.update(cx, |text_thread_editor, cx| {
501 text_thread_editor
502 .text_thread()
503 .update(cx, |text_thread, cx| {
504 text_thread.set_custom_summary(new_summary, cx);
505 })
506 })
507 }
508 EditorEvent::Blurred => {
509 if editor.read(cx).text(cx).is_empty() {
510 let summary = text_thread_editor
511 .read(cx)
512 .text_thread()
513 .read(cx)
514 .summary()
515 .or_default();
516
517 editor.update(cx, |editor, cx| {
518 editor.set_text(summary, window, cx);
519 });
520 }
521 }
522 _ => {}
523 }
524 }
525 }),
526 window.subscribe(&text_thread_editor.read(cx).text_thread().clone(), cx, {
527 let editor = editor.clone();
528 move |text_thread, event, window, cx| match event {
529 TextThreadEvent::SummaryGenerated => {
530 let summary = text_thread.read(cx).summary().or_default();
531
532 editor.update(cx, |editor, cx| {
533 editor.set_text(summary, window, cx);
534 })
535 }
536 TextThreadEvent::PathChanged { .. } => {}
537 _ => {}
538 }
539 }),
540 ];
541
542 let buffer_search_bar =
543 cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx));
544 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
545 buffer_search_bar.set_active_pane_item(Some(&text_thread_editor), window, cx)
546 });
547
548 Self::TextThread {
549 text_thread_editor,
550 title_editor: editor,
551 buffer_search_bar,
552 _subscriptions: subscriptions,
553 }
554 }
555}
556
557pub struct AgentPanel {
558 workspace: WeakEntity<Workspace>,
559 /// Workspace id is used as a database key
560 workspace_id: Option<WorkspaceId>,
561 user_store: Entity<UserStore>,
562 project: Entity<Project>,
563 fs: Arc<dyn Fs>,
564 language_registry: Arc<LanguageRegistry>,
565 acp_history: Entity<ThreadHistory>,
566 text_thread_history: Entity<TextThreadHistory>,
567 thread_store: Entity<ThreadStore>,
568 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
569 prompt_store: Option<Entity<PromptStore>>,
570 context_server_registry: Entity<ContextServerRegistry>,
571 configuration: Option<Entity<AgentConfiguration>>,
572 configuration_subscription: Option<Subscription>,
573 focus_handle: FocusHandle,
574 active_view: ActiveView,
575 previous_view: Option<ActiveView>,
576 background_threads: HashMap<acp::SessionId, Entity<ConnectionView>>,
577 new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
578 start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
579 agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
580 agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
581 agent_navigation_menu: Option<Entity<ContextMenu>>,
582 _extension_subscription: Option<Subscription>,
583 width: Option<Pixels>,
584 height: Option<Pixels>,
585 zoomed: bool,
586 pending_serialization: Option<Task<Result<()>>>,
587 onboarding: Entity<AgentPanelOnboarding>,
588 selected_agent: AgentType,
589 start_thread_in: StartThreadIn,
590 worktree_creation_status: Option<WorktreeCreationStatus>,
591 _thread_view_subscription: Option<Subscription>,
592 _active_thread_focus_subscription: Option<Subscription>,
593 _worktree_creation_task: Option<Task<()>>,
594 show_trust_workspace_message: bool,
595 last_configuration_error_telemetry: Option<String>,
596 on_boarding_upsell_dismissed: AtomicBool,
597 _active_view_observation: Option<Subscription>,
598}
599
600impl AgentPanel {
601 fn serialize(&mut self, cx: &mut App) {
602 let Some(workspace_id) = self.workspace_id else {
603 return;
604 };
605
606 let width = self.width;
607 let selected_agent = self.selected_agent.clone();
608 let start_thread_in = Some(self.start_thread_in);
609
610 let last_active_thread = self.active_agent_thread(cx).map(|thread| {
611 let thread = thread.read(cx);
612 let title = thread.title();
613 SerializedActiveThread {
614 session_id: thread.session_id().0.to_string(),
615 agent_type: self.selected_agent.clone(),
616 title: if title.as_ref() != DEFAULT_THREAD_TITLE {
617 Some(title.to_string())
618 } else {
619 None
620 },
621 cwd: None,
622 }
623 });
624
625 self.pending_serialization = Some(cx.background_spawn(async move {
626 save_serialized_panel(
627 workspace_id,
628 SerializedAgentPanel {
629 width,
630 selected_agent: Some(selected_agent),
631 last_active_thread,
632 start_thread_in,
633 },
634 )
635 .await?;
636 anyhow::Ok(())
637 }));
638 }
639
640 pub fn load(
641 workspace: WeakEntity<Workspace>,
642 prompt_builder: Arc<PromptBuilder>,
643 mut cx: AsyncWindowContext,
644 ) -> Task<Result<Entity<Self>>> {
645 let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
646 cx.spawn(async move |cx| {
647 let prompt_store = match prompt_store {
648 Ok(prompt_store) => prompt_store.await.ok(),
649 Err(_) => None,
650 };
651 let workspace_id = workspace
652 .read_with(cx, |workspace, _| workspace.database_id())
653 .ok()
654 .flatten();
655
656 let serialized_panel = cx
657 .background_spawn(async move {
658 workspace_id
659 .and_then(read_serialized_panel)
660 .or_else(read_legacy_serialized_panel)
661 })
662 .await;
663
664 let slash_commands = Arc::new(SlashCommandWorkingSet::default());
665 let text_thread_store = workspace
666 .update(cx, |workspace, cx| {
667 let project = workspace.project().clone();
668 assistant_text_thread::TextThreadStore::new(
669 project,
670 prompt_builder,
671 slash_commands,
672 cx,
673 )
674 })?
675 .await?;
676
677 let last_active_thread = if let Some(thread_info) = serialized_panel
678 .as_ref()
679 .and_then(|p| p.last_active_thread.clone())
680 {
681 if thread_info.agent_type.is_native() {
682 let session_id = acp::SessionId::new(thread_info.session_id.clone());
683 let load_result = cx.update(|_window, cx| {
684 let thread_store = ThreadStore::global(cx);
685 thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
686 });
687 let thread_exists = if let Ok(task) = load_result {
688 task.await.ok().flatten().is_some()
689 } else {
690 false
691 };
692 if thread_exists {
693 Some(thread_info)
694 } else {
695 log::warn!(
696 "last active thread {} not found in database, skipping restoration",
697 thread_info.session_id
698 );
699 None
700 }
701 } else {
702 Some(thread_info)
703 }
704 } else {
705 None
706 };
707
708 let panel = workspace.update_in(cx, |workspace, window, cx| {
709 let panel =
710 cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
711
712 if let Some(serialized_panel) = &serialized_panel {
713 panel.update(cx, |panel, cx| {
714 panel.width = serialized_panel.width.map(|w| w.round());
715 if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
716 panel.selected_agent = selected_agent;
717 }
718 if let Some(start_thread_in) = serialized_panel.start_thread_in {
719 let is_worktree_flag_enabled =
720 cx.has_flag::<AgentV2FeatureFlag>();
721 let is_valid = match &start_thread_in {
722 StartThreadIn::LocalProject => true,
723 StartThreadIn::NewWorktree => {
724 let project = panel.project.read(cx);
725 is_worktree_flag_enabled && !project.is_via_collab()
726 }
727 };
728 if is_valid {
729 panel.start_thread_in = start_thread_in;
730 } else {
731 log::info!(
732 "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
733 start_thread_in,
734 );
735 }
736 }
737 cx.notify();
738 });
739 }
740
741 if let Some(thread_info) = last_active_thread {
742 let agent_type = thread_info.agent_type.clone();
743 panel.update(cx, |panel, cx| {
744 panel.selected_agent = agent_type;
745 panel.load_agent_thread_inner(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), false, window, cx);
746 });
747 }
748 panel
749 })?;
750
751 Ok(panel)
752 })
753 }
754
755 pub(crate) fn new(
756 workspace: &Workspace,
757 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
758 prompt_store: Option<Entity<PromptStore>>,
759 window: &mut Window,
760 cx: &mut Context<Self>,
761 ) -> Self {
762 let fs = workspace.app_state().fs.clone();
763 let user_store = workspace.app_state().user_store.clone();
764 let project = workspace.project();
765 let language_registry = project.read(cx).languages().clone();
766 let client = workspace.client().clone();
767 let workspace_id = workspace.database_id();
768 let workspace = workspace.weak_handle();
769
770 let context_server_registry =
771 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
772
773 let thread_store = ThreadStore::global(cx);
774 let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx));
775 let text_thread_history =
776 cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
777 cx.subscribe_in(
778 &acp_history,
779 window,
780 |this, _, event, window, cx| match event {
781 ThreadHistoryEvent::Open(thread) => {
782 this.load_agent_thread(
783 thread.session_id.clone(),
784 thread.cwd.clone(),
785 thread.title.clone(),
786 window,
787 cx,
788 );
789 }
790 },
791 )
792 .detach();
793 cx.subscribe_in(
794 &text_thread_history,
795 window,
796 |this, _, event, window, cx| match event {
797 TextThreadHistoryEvent::Open(thread) => {
798 this.open_saved_text_thread(thread.path.clone(), window, cx)
799 .detach_and_log_err(cx);
800 }
801 },
802 )
803 .detach();
804
805 let active_view = ActiveView::Uninitialized;
806
807 let weak_panel = cx.entity().downgrade();
808
809 window.defer(cx, move |window, cx| {
810 let panel = weak_panel.clone();
811 let agent_navigation_menu =
812 ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
813 if let Some(panel) = panel.upgrade() {
814 if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) {
815 menu =
816 Self::populate_recently_updated_menu_section(menu, panel, kind, cx);
817 let view_all_label = match kind {
818 HistoryKind::AgentThreads => "View All",
819 HistoryKind::TextThreads => "View All Text Threads",
820 };
821 menu = menu.action(view_all_label, Box::new(OpenHistory));
822 }
823 }
824
825 menu = menu
826 .fixed_width(px(320.).into())
827 .keep_open_on_confirm(false)
828 .key_context("NavigationMenu");
829
830 menu
831 });
832 weak_panel
833 .update(cx, |panel, cx| {
834 cx.subscribe_in(
835 &agent_navigation_menu,
836 window,
837 |_, menu, _: &DismissEvent, window, cx| {
838 menu.update(cx, |menu, _| {
839 menu.clear_selected();
840 });
841 cx.focus_self(window);
842 },
843 )
844 .detach();
845 panel.agent_navigation_menu = Some(agent_navigation_menu);
846 })
847 .ok();
848 });
849
850 let weak_panel = cx.entity().downgrade();
851 let onboarding = cx.new(|cx| {
852 AgentPanelOnboarding::new(
853 user_store.clone(),
854 client,
855 move |_window, cx| {
856 weak_panel
857 .update(cx, |panel, _| {
858 panel
859 .on_boarding_upsell_dismissed
860 .store(true, Ordering::Release);
861 })
862 .ok();
863 OnboardingUpsell::set_dismissed(true, cx);
864 },
865 cx,
866 )
867 });
868
869 // Subscribe to extension events to sync agent servers when extensions change
870 let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
871 {
872 Some(
873 cx.subscribe(&extension_events, |this, _source, event, cx| match event {
874 extension::Event::ExtensionInstalled(_)
875 | extension::Event::ExtensionUninstalled(_)
876 | extension::Event::ExtensionsInstalledChanged => {
877 this.sync_agent_servers_from_extensions(cx);
878 }
879 _ => {}
880 }),
881 )
882 } else {
883 None
884 };
885
886 let mut panel = Self {
887 workspace_id,
888 active_view,
889 workspace,
890 user_store,
891 project: project.clone(),
892 fs: fs.clone(),
893 language_registry,
894 text_thread_store,
895 prompt_store,
896 configuration: None,
897 configuration_subscription: None,
898 focus_handle: cx.focus_handle(),
899 context_server_registry,
900 previous_view: None,
901 background_threads: HashMap::default(),
902 new_thread_menu_handle: PopoverMenuHandle::default(),
903 start_thread_in_menu_handle: PopoverMenuHandle::default(),
904 agent_panel_menu_handle: PopoverMenuHandle::default(),
905 agent_navigation_menu_handle: PopoverMenuHandle::default(),
906 agent_navigation_menu: None,
907 _extension_subscription: extension_subscription,
908 width: None,
909 height: None,
910 zoomed: false,
911 pending_serialization: None,
912 onboarding,
913 acp_history,
914 text_thread_history,
915 thread_store,
916 selected_agent: AgentType::default(),
917 start_thread_in: StartThreadIn::default(),
918 worktree_creation_status: None,
919 _thread_view_subscription: None,
920 _active_thread_focus_subscription: None,
921 _worktree_creation_task: None,
922 show_trust_workspace_message: false,
923 last_configuration_error_telemetry: None,
924 on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
925 _active_view_observation: None,
926 };
927
928 // Initial sync of agent servers from extensions
929 panel.sync_agent_servers_from_extensions(cx);
930 panel
931 }
932
933 pub fn toggle_focus(
934 workspace: &mut Workspace,
935 _: &ToggleFocus,
936 window: &mut Window,
937 cx: &mut Context<Workspace>,
938 ) {
939 if workspace
940 .panel::<Self>(cx)
941 .is_some_and(|panel| panel.read(cx).enabled(cx))
942 {
943 workspace.toggle_panel_focus::<Self>(window, cx);
944 }
945 }
946
947 pub fn toggle(
948 workspace: &mut Workspace,
949 _: &Toggle,
950 window: &mut Window,
951 cx: &mut Context<Workspace>,
952 ) {
953 if workspace
954 .panel::<Self>(cx)
955 .is_some_and(|panel| panel.read(cx).enabled(cx))
956 {
957 if !workspace.toggle_panel_focus::<Self>(window, cx) {
958 workspace.close_panel::<Self>(window, cx);
959 }
960 }
961 }
962
963 pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
964 &self.prompt_store
965 }
966
967 pub fn thread_store(&self) -> &Entity<ThreadStore> {
968 &self.thread_store
969 }
970
971 pub fn history(&self) -> &Entity<ThreadHistory> {
972 &self.acp_history
973 }
974
975 pub fn open_thread(
976 &mut self,
977 session_id: acp::SessionId,
978 cwd: Option<PathBuf>,
979 title: Option<SharedString>,
980 window: &mut Window,
981 cx: &mut Context<Self>,
982 ) {
983 self.external_thread(
984 Some(crate::ExternalAgent::NativeAgent),
985 Some(session_id),
986 cwd,
987 title,
988 None,
989 true,
990 window,
991 cx,
992 );
993 }
994
995 pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
996 &self.context_server_registry
997 }
998
999 pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
1000 let workspace_read = workspace.read(cx);
1001
1002 workspace_read
1003 .panel::<AgentPanel>(cx)
1004 .map(|panel| {
1005 let panel_id = Entity::entity_id(&panel);
1006
1007 workspace_read.all_docks().iter().any(|dock| {
1008 dock.read(cx)
1009 .visible_panel()
1010 .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1011 })
1012 })
1013 .unwrap_or(false)
1014 }
1015
1016 pub fn active_connection_view(&self) -> Option<&Entity<ConnectionView>> {
1017 match &self.active_view {
1018 ActiveView::AgentThread { server_view, .. } => Some(server_view),
1019 ActiveView::Uninitialized
1020 | ActiveView::TextThread { .. }
1021 | ActiveView::History { .. }
1022 | ActiveView::Configuration => None,
1023 }
1024 }
1025
1026 pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1027 self.new_agent_thread(AgentType::NativeAgent, window, cx);
1028 }
1029
1030 fn new_native_agent_thread_from_summary(
1031 &mut self,
1032 action: &NewNativeAgentThreadFromSummary,
1033 window: &mut Window,
1034 cx: &mut Context<Self>,
1035 ) {
1036 let Some(thread) = self
1037 .acp_history
1038 .read(cx)
1039 .session_for_id(&action.from_session_id)
1040 else {
1041 return;
1042 };
1043
1044 self.external_thread(
1045 Some(ExternalAgent::NativeAgent),
1046 None,
1047 None,
1048 None,
1049 Some(AgentInitialContent::ThreadSummary {
1050 session_id: thread.session_id,
1051 title: thread.title,
1052 }),
1053 true,
1054 window,
1055 cx,
1056 );
1057 }
1058
1059 fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1060 telemetry::event!("Agent Thread Started", agent = "zed-text");
1061
1062 let context = self
1063 .text_thread_store
1064 .update(cx, |context_store, cx| context_store.create(cx));
1065 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx)
1066 .log_err()
1067 .flatten();
1068
1069 let text_thread_editor = cx.new(|cx| {
1070 let mut editor = TextThreadEditor::for_text_thread(
1071 context,
1072 self.fs.clone(),
1073 self.workspace.clone(),
1074 self.project.clone(),
1075 lsp_adapter_delegate,
1076 window,
1077 cx,
1078 );
1079 editor.insert_default_prompt(window, cx);
1080 editor
1081 });
1082
1083 if self.selected_agent != AgentType::TextThread {
1084 self.selected_agent = AgentType::TextThread;
1085 self.serialize(cx);
1086 }
1087
1088 self.set_active_view(
1089 ActiveView::text_thread(
1090 text_thread_editor.clone(),
1091 self.language_registry.clone(),
1092 window,
1093 cx,
1094 ),
1095 true,
1096 window,
1097 cx,
1098 );
1099 text_thread_editor.focus_handle(cx).focus(window, cx);
1100 }
1101
1102 fn external_thread(
1103 &mut self,
1104 agent_choice: Option<crate::ExternalAgent>,
1105 resume_session_id: Option<acp::SessionId>,
1106 cwd: Option<PathBuf>,
1107 title: Option<SharedString>,
1108 initial_content: Option<AgentInitialContent>,
1109 focus: bool,
1110 window: &mut Window,
1111 cx: &mut Context<Self>,
1112 ) {
1113 let workspace = self.workspace.clone();
1114 let project = self.project.clone();
1115 let fs = self.fs.clone();
1116 let is_via_collab = self.project.read(cx).is_via_collab();
1117
1118 const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
1119
1120 #[derive(Serialize, Deserialize)]
1121 struct LastUsedExternalAgent {
1122 agent: crate::ExternalAgent,
1123 }
1124
1125 let thread_store = self.thread_store.clone();
1126
1127 if let Some(agent) = agent_choice {
1128 cx.background_spawn({
1129 let agent = agent.clone();
1130 async move {
1131 if let Some(serialized) =
1132 serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
1133 {
1134 KEY_VALUE_STORE
1135 .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
1136 .await
1137 .log_err();
1138 }
1139 }
1140 })
1141 .detach();
1142
1143 let server = agent.server(fs, thread_store);
1144 self.create_external_thread(
1145 server,
1146 resume_session_id,
1147 cwd,
1148 title,
1149 initial_content,
1150 workspace,
1151 project,
1152 agent,
1153 focus,
1154 window,
1155 cx,
1156 );
1157 } else {
1158 cx.spawn_in(window, async move |this, cx| {
1159 let ext_agent = if is_via_collab {
1160 ExternalAgent::NativeAgent
1161 } else {
1162 cx.background_spawn(async move {
1163 KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
1164 })
1165 .await
1166 .log_err()
1167 .flatten()
1168 .and_then(|value| {
1169 serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
1170 })
1171 .map(|agent| agent.agent)
1172 .unwrap_or(ExternalAgent::NativeAgent)
1173 };
1174
1175 let server = ext_agent.server(fs, thread_store);
1176 this.update_in(cx, |agent_panel, window, cx| {
1177 agent_panel.create_external_thread(
1178 server,
1179 resume_session_id,
1180 cwd,
1181 title,
1182 initial_content,
1183 workspace,
1184 project,
1185 ext_agent,
1186 focus,
1187 window,
1188 cx,
1189 );
1190 })?;
1191
1192 anyhow::Ok(())
1193 })
1194 .detach_and_log_err(cx);
1195 }
1196 }
1197
1198 fn deploy_rules_library(
1199 &mut self,
1200 action: &OpenRulesLibrary,
1201 _window: &mut Window,
1202 cx: &mut Context<Self>,
1203 ) {
1204 open_rules_library(
1205 self.language_registry.clone(),
1206 Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1207 Rc::new(|| {
1208 Rc::new(SlashCommandCompletionProvider::new(
1209 Arc::new(SlashCommandWorkingSet::default()),
1210 None,
1211 None,
1212 ))
1213 }),
1214 action
1215 .prompt_to_select
1216 .map(|uuid| UserPromptId(uuid).into()),
1217 cx,
1218 )
1219 .detach_and_log_err(cx);
1220 }
1221
1222 fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1223 let Some(thread_view) = self.active_connection_view() else {
1224 return;
1225 };
1226
1227 let Some(active_thread) = thread_view.read(cx).active_thread().cloned() else {
1228 return;
1229 };
1230
1231 active_thread.update(cx, |active_thread, cx| {
1232 active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1233 active_thread.focus_handle(cx).focus(window, cx);
1234 })
1235 }
1236
1237 fn history_kind_for_selected_agent(&self, cx: &App) -> Option<HistoryKind> {
1238 match self.selected_agent {
1239 AgentType::NativeAgent => Some(HistoryKind::AgentThreads),
1240 AgentType::TextThread => Some(HistoryKind::TextThreads),
1241 AgentType::Custom { .. } => {
1242 if self.acp_history.read(cx).has_session_list() {
1243 Some(HistoryKind::AgentThreads)
1244 } else {
1245 None
1246 }
1247 }
1248 }
1249 }
1250
1251 fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1252 let Some(kind) = self.history_kind_for_selected_agent(cx) else {
1253 return;
1254 };
1255
1256 if let ActiveView::History { kind: active_kind } = self.active_view {
1257 if active_kind == kind {
1258 if let Some(previous_view) = self.previous_view.take() {
1259 self.set_active_view(previous_view, true, window, cx);
1260 }
1261 return;
1262 }
1263 }
1264
1265 self.set_active_view(ActiveView::History { kind }, true, window, cx);
1266 cx.notify();
1267 }
1268
1269 pub(crate) fn open_saved_text_thread(
1270 &mut self,
1271 path: Arc<Path>,
1272 window: &mut Window,
1273 cx: &mut Context<Self>,
1274 ) -> Task<Result<()>> {
1275 let text_thread_task = self
1276 .text_thread_store
1277 .update(cx, |store, cx| store.open_local(path, cx));
1278 cx.spawn_in(window, async move |this, cx| {
1279 let text_thread = text_thread_task.await?;
1280 this.update_in(cx, |this, window, cx| {
1281 this.open_text_thread(text_thread, window, cx);
1282 })
1283 })
1284 }
1285
1286 pub(crate) fn open_text_thread(
1287 &mut self,
1288 text_thread: Entity<TextThread>,
1289 window: &mut Window,
1290 cx: &mut Context<Self>,
1291 ) {
1292 let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project.clone(), cx)
1293 .log_err()
1294 .flatten();
1295 let editor = cx.new(|cx| {
1296 TextThreadEditor::for_text_thread(
1297 text_thread,
1298 self.fs.clone(),
1299 self.workspace.clone(),
1300 self.project.clone(),
1301 lsp_adapter_delegate,
1302 window,
1303 cx,
1304 )
1305 });
1306
1307 if self.selected_agent != AgentType::TextThread {
1308 self.selected_agent = AgentType::TextThread;
1309 self.serialize(cx);
1310 }
1311
1312 self.set_active_view(
1313 ActiveView::text_thread(editor, self.language_registry.clone(), window, cx),
1314 true,
1315 window,
1316 cx,
1317 );
1318 }
1319
1320 pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1321 match self.active_view {
1322 ActiveView::Configuration | ActiveView::History { .. } => {
1323 if let Some(previous_view) = self.previous_view.take() {
1324 self.set_active_view(previous_view, true, window, cx);
1325 }
1326 cx.notify();
1327 }
1328 _ => {}
1329 }
1330 }
1331
1332 pub fn toggle_navigation_menu(
1333 &mut self,
1334 _: &ToggleNavigationMenu,
1335 window: &mut Window,
1336 cx: &mut Context<Self>,
1337 ) {
1338 if self.history_kind_for_selected_agent(cx).is_none() {
1339 return;
1340 }
1341 self.agent_navigation_menu_handle.toggle(window, cx);
1342 }
1343
1344 pub fn toggle_options_menu(
1345 &mut self,
1346 _: &ToggleOptionsMenu,
1347 window: &mut Window,
1348 cx: &mut Context<Self>,
1349 ) {
1350 self.agent_panel_menu_handle.toggle(window, cx);
1351 }
1352
1353 pub fn toggle_new_thread_menu(
1354 &mut self,
1355 _: &ToggleNewThreadMenu,
1356 window: &mut Window,
1357 cx: &mut Context<Self>,
1358 ) {
1359 self.new_thread_menu_handle.toggle(window, cx);
1360 }
1361
1362 pub fn toggle_start_thread_in_selector(
1363 &mut self,
1364 _: &ToggleStartThreadInSelector,
1365 window: &mut Window,
1366 cx: &mut Context<Self>,
1367 ) {
1368 self.start_thread_in_menu_handle.toggle(window, cx);
1369 }
1370
1371 pub fn increase_font_size(
1372 &mut self,
1373 action: &IncreaseBufferFontSize,
1374 _: &mut Window,
1375 cx: &mut Context<Self>,
1376 ) {
1377 self.handle_font_size_action(action.persist, px(1.0), cx);
1378 }
1379
1380 pub fn decrease_font_size(
1381 &mut self,
1382 action: &DecreaseBufferFontSize,
1383 _: &mut Window,
1384 cx: &mut Context<Self>,
1385 ) {
1386 self.handle_font_size_action(action.persist, px(-1.0), cx);
1387 }
1388
1389 fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1390 match self.active_view.which_font_size_used() {
1391 WhichFontSize::AgentFont => {
1392 if persist {
1393 update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1394 let agent_ui_font_size =
1395 ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1396 let agent_buffer_font_size =
1397 ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1398
1399 let _ = settings
1400 .theme
1401 .agent_ui_font_size
1402 .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into());
1403 let _ = settings.theme.agent_buffer_font_size.insert(
1404 f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(),
1405 );
1406 });
1407 } else {
1408 theme::adjust_agent_ui_font_size(cx, |size| size + delta);
1409 theme::adjust_agent_buffer_font_size(cx, |size| size + delta);
1410 }
1411 }
1412 WhichFontSize::BufferFont => {
1413 // Prompt editor uses the buffer font size, so allow the action to propagate to the
1414 // default handler that changes that font size.
1415 cx.propagate();
1416 }
1417 WhichFontSize::None => {}
1418 }
1419 }
1420
1421 pub fn reset_font_size(
1422 &mut self,
1423 action: &ResetBufferFontSize,
1424 _: &mut Window,
1425 cx: &mut Context<Self>,
1426 ) {
1427 if action.persist {
1428 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1429 settings.theme.agent_ui_font_size = None;
1430 settings.theme.agent_buffer_font_size = None;
1431 });
1432 } else {
1433 theme::reset_agent_ui_font_size(cx);
1434 theme::reset_agent_buffer_font_size(cx);
1435 }
1436 }
1437
1438 pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1439 theme::reset_agent_ui_font_size(cx);
1440 theme::reset_agent_buffer_font_size(cx);
1441 }
1442
1443 pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1444 if self.zoomed {
1445 cx.emit(PanelEvent::ZoomOut);
1446 } else {
1447 if !self.focus_handle(cx).contains_focused(window, cx) {
1448 cx.focus_self(window);
1449 }
1450 cx.emit(PanelEvent::ZoomIn);
1451 }
1452 }
1453
1454 pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1455 let agent_server_store = self.project.read(cx).agent_server_store().clone();
1456 let context_server_store = self.project.read(cx).context_server_store();
1457 let fs = self.fs.clone();
1458
1459 self.set_active_view(ActiveView::Configuration, true, window, cx);
1460 self.configuration = Some(cx.new(|cx| {
1461 AgentConfiguration::new(
1462 fs,
1463 agent_server_store,
1464 context_server_store,
1465 self.context_server_registry.clone(),
1466 self.language_registry.clone(),
1467 self.workspace.clone(),
1468 window,
1469 cx,
1470 )
1471 }));
1472
1473 if let Some(configuration) = self.configuration.as_ref() {
1474 self.configuration_subscription = Some(cx.subscribe_in(
1475 configuration,
1476 window,
1477 Self::handle_agent_configuration_event,
1478 ));
1479
1480 configuration.focus_handle(cx).focus(window, cx);
1481 }
1482 }
1483
1484 pub(crate) fn open_active_thread_as_markdown(
1485 &mut self,
1486 _: &OpenActiveThreadAsMarkdown,
1487 window: &mut Window,
1488 cx: &mut Context<Self>,
1489 ) {
1490 if let Some(workspace) = self.workspace.upgrade()
1491 && let Some(thread_view) = self.active_connection_view()
1492 && let Some(active_thread) = thread_view.read(cx).active_thread().cloned()
1493 {
1494 active_thread.update(cx, |thread, cx| {
1495 thread
1496 .open_thread_as_markdown(workspace, window, cx)
1497 .detach_and_log_err(cx);
1498 });
1499 }
1500 }
1501
1502 fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1503 let Some(thread) = self.active_native_agent_thread(cx) else {
1504 Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1505 return;
1506 };
1507
1508 let workspace = self.workspace.clone();
1509 let load_task = thread.read(cx).to_db(cx);
1510
1511 cx.spawn_in(window, async move |_this, cx| {
1512 let db_thread = load_task.await;
1513 let shared_thread = SharedThread::from_db_thread(&db_thread);
1514 let thread_data = shared_thread.to_bytes()?;
1515 let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1516
1517 cx.update(|_window, cx| {
1518 cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1519 if let Some(workspace) = workspace.upgrade() {
1520 workspace.update(cx, |workspace, cx| {
1521 struct ThreadCopiedToast;
1522 workspace.show_toast(
1523 workspace::Toast::new(
1524 workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1525 "Thread copied to clipboard (base64 encoded)",
1526 )
1527 .autohide(),
1528 cx,
1529 );
1530 });
1531 }
1532 })?;
1533
1534 anyhow::Ok(())
1535 })
1536 .detach_and_log_err(cx);
1537 }
1538
1539 fn show_deferred_toast(
1540 workspace: &WeakEntity<workspace::Workspace>,
1541 message: &'static str,
1542 cx: &mut App,
1543 ) {
1544 let workspace = workspace.clone();
1545 cx.defer(move |cx| {
1546 if let Some(workspace) = workspace.upgrade() {
1547 workspace.update(cx, |workspace, cx| {
1548 struct ClipboardToast;
1549 workspace.show_toast(
1550 workspace::Toast::new(
1551 workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1552 message,
1553 )
1554 .autohide(),
1555 cx,
1556 );
1557 });
1558 }
1559 });
1560 }
1561
1562 fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1563 let Some(clipboard) = cx.read_from_clipboard() else {
1564 Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1565 return;
1566 };
1567
1568 let Some(encoded) = clipboard.text() else {
1569 Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1570 return;
1571 };
1572
1573 let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1574 {
1575 Ok(data) => data,
1576 Err(_) => {
1577 Self::show_deferred_toast(
1578 &self.workspace,
1579 "Failed to decode clipboard content (expected base64)",
1580 cx,
1581 );
1582 return;
1583 }
1584 };
1585
1586 let shared_thread = match SharedThread::from_bytes(&thread_data) {
1587 Ok(thread) => thread,
1588 Err(_) => {
1589 Self::show_deferred_toast(
1590 &self.workspace,
1591 "Failed to parse thread data from clipboard",
1592 cx,
1593 );
1594 return;
1595 }
1596 };
1597
1598 let db_thread = shared_thread.to_db_thread();
1599 let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1600 let thread_store = self.thread_store.clone();
1601 let title = db_thread.title.clone();
1602 let workspace = self.workspace.clone();
1603
1604 cx.spawn_in(window, async move |this, cx| {
1605 thread_store
1606 .update(&mut cx.clone(), |store, cx| {
1607 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1608 })
1609 .await?;
1610
1611 this.update_in(cx, |this, window, cx| {
1612 this.open_thread(session_id, None, Some(title), window, cx);
1613 })?;
1614
1615 this.update_in(cx, |_, _window, cx| {
1616 if let Some(workspace) = workspace.upgrade() {
1617 workspace.update(cx, |workspace, cx| {
1618 struct ThreadLoadedToast;
1619 workspace.show_toast(
1620 workspace::Toast::new(
1621 workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1622 "Thread loaded from clipboard",
1623 )
1624 .autohide(),
1625 cx,
1626 );
1627 });
1628 }
1629 })?;
1630
1631 anyhow::Ok(())
1632 })
1633 .detach_and_log_err(cx);
1634 }
1635
1636 fn handle_agent_configuration_event(
1637 &mut self,
1638 _entity: &Entity<AgentConfiguration>,
1639 event: &AssistantConfigurationEvent,
1640 window: &mut Window,
1641 cx: &mut Context<Self>,
1642 ) {
1643 match event {
1644 AssistantConfigurationEvent::NewThread(provider) => {
1645 if LanguageModelRegistry::read_global(cx)
1646 .default_model()
1647 .is_none_or(|model| model.provider.id() != provider.id())
1648 && let Some(model) = provider.default_model(cx)
1649 {
1650 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1651 let provider = model.provider_id().0.to_string();
1652 let enable_thinking = model.supports_thinking();
1653 let effort = model
1654 .default_effort_level()
1655 .map(|effort| effort.value.to_string());
1656 let model = model.id().0.to_string();
1657 settings
1658 .agent
1659 .get_or_insert_default()
1660 .set_model(LanguageModelSelection {
1661 provider: LanguageModelProviderSetting(provider),
1662 model,
1663 enable_thinking,
1664 effort,
1665 })
1666 });
1667 }
1668
1669 self.new_thread(&NewThread, window, cx);
1670 if let Some((thread, model)) = self
1671 .active_native_agent_thread(cx)
1672 .zip(provider.default_model(cx))
1673 {
1674 thread.update(cx, |thread, cx| {
1675 thread.set_model(model, cx);
1676 });
1677 }
1678 }
1679 }
1680 }
1681
1682 pub fn as_active_server_view(&self) -> Option<&Entity<ConnectionView>> {
1683 match &self.active_view {
1684 ActiveView::AgentThread { server_view } => Some(server_view),
1685 _ => None,
1686 }
1687 }
1688
1689 pub fn as_active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
1690 let server_view = self.as_active_server_view()?;
1691 server_view.read(cx).active_thread().cloned()
1692 }
1693
1694 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1695 match &self.active_view {
1696 ActiveView::AgentThread { server_view, .. } => server_view
1697 .read(cx)
1698 .active_thread()
1699 .map(|r| r.read(cx).thread.clone()),
1700 _ => None,
1701 }
1702 }
1703
1704 /// Returns the primary thread views for all retained connections: the
1705 pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
1706 self.background_threads.contains_key(session_id)
1707 }
1708
1709 /// active thread plus any background threads that are still running or
1710 /// completed but unseen.
1711 pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
1712 let mut views = Vec::new();
1713
1714 if let Some(server_view) = self.as_active_server_view() {
1715 if let Some(thread_view) = server_view.read(cx).parent_thread(cx) {
1716 views.push(thread_view);
1717 }
1718 }
1719
1720 for server_view in self.background_threads.values() {
1721 if let Some(thread_view) = server_view.read(cx).parent_thread(cx) {
1722 views.push(thread_view);
1723 }
1724 }
1725
1726 views
1727 }
1728
1729 fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
1730 let ActiveView::AgentThread { server_view } = old_view else {
1731 return;
1732 };
1733
1734 let Some(thread_view) = server_view.read(cx).parent_thread(cx) else {
1735 return;
1736 };
1737
1738 let thread = &thread_view.read(cx).thread;
1739 let (status, session_id) = {
1740 let thread = thread.read(cx);
1741 (thread.status(), thread.session_id().clone())
1742 };
1743
1744 if status != ThreadStatus::Generating {
1745 return;
1746 }
1747
1748 self.background_threads.insert(session_id, server_view);
1749 }
1750
1751 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
1752 match &self.active_view {
1753 ActiveView::AgentThread { server_view, .. } => {
1754 server_view.read(cx).as_native_thread(cx)
1755 }
1756 _ => None,
1757 }
1758 }
1759
1760 pub(crate) fn active_text_thread_editor(&self) -> Option<Entity<TextThreadEditor>> {
1761 match &self.active_view {
1762 ActiveView::TextThread {
1763 text_thread_editor, ..
1764 } => Some(text_thread_editor.clone()),
1765 _ => None,
1766 }
1767 }
1768
1769 fn set_active_view(
1770 &mut self,
1771 new_view: ActiveView,
1772 focus: bool,
1773 window: &mut Window,
1774 cx: &mut Context<Self>,
1775 ) {
1776 let was_in_agent_history = matches!(
1777 self.active_view,
1778 ActiveView::History {
1779 kind: HistoryKind::AgentThreads
1780 }
1781 );
1782 let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
1783 let current_is_history = matches!(self.active_view, ActiveView::History { .. });
1784 let new_is_history = matches!(new_view, ActiveView::History { .. });
1785
1786 let current_is_config = matches!(self.active_view, ActiveView::Configuration);
1787 let new_is_config = matches!(new_view, ActiveView::Configuration);
1788
1789 let current_is_overlay = current_is_history || current_is_config;
1790 let new_is_overlay = new_is_history || new_is_config;
1791
1792 if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
1793 self.active_view = new_view;
1794 } else if !current_is_overlay && new_is_overlay {
1795 self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
1796 } else {
1797 let old_view = std::mem::replace(&mut self.active_view, new_view);
1798 if !new_is_overlay {
1799 if let Some(previous) = self.previous_view.take() {
1800 self.retain_running_thread(previous, cx);
1801 }
1802 }
1803 self.retain_running_thread(old_view, cx);
1804 }
1805
1806 // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
1807 // so the panel can intercept the first send for worktree creation.
1808 // Re-subscribe whenever the ConnectionView changes, since the inner
1809 // ThreadView may have been replaced (e.g. navigating between threads).
1810 self._active_view_observation = match &self.active_view {
1811 ActiveView::AgentThread { server_view } => {
1812 self._thread_view_subscription =
1813 Self::subscribe_to_active_thread_view(server_view, window, cx);
1814 let focus_handle = server_view.focus_handle(cx);
1815 self._active_thread_focus_subscription =
1816 Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
1817 cx.emit(AgentPanelEvent::ThreadFocused);
1818 cx.notify();
1819 }));
1820 Some(
1821 cx.observe_in(server_view, window, |this, server_view, window, cx| {
1822 this._thread_view_subscription =
1823 Self::subscribe_to_active_thread_view(&server_view, window, cx);
1824 cx.emit(AgentPanelEvent::ActiveViewChanged);
1825 this.serialize(cx);
1826 cx.notify();
1827 }),
1828 )
1829 }
1830 _ => {
1831 self._thread_view_subscription = None;
1832 self._active_thread_focus_subscription = None;
1833 None
1834 }
1835 };
1836
1837 let is_in_agent_history = matches!(
1838 self.active_view,
1839 ActiveView::History {
1840 kind: HistoryKind::AgentThreads
1841 }
1842 );
1843
1844 if !was_in_agent_history && is_in_agent_history {
1845 self.acp_history
1846 .update(cx, |history, cx| history.refresh_full_history(cx));
1847 }
1848
1849 if focus {
1850 self.focus_handle(cx).focus(window, cx);
1851 }
1852 cx.emit(AgentPanelEvent::ActiveViewChanged);
1853 }
1854
1855 fn populate_recently_updated_menu_section(
1856 mut menu: ContextMenu,
1857 panel: Entity<Self>,
1858 kind: HistoryKind,
1859 cx: &mut Context<ContextMenu>,
1860 ) -> ContextMenu {
1861 match kind {
1862 HistoryKind::AgentThreads => {
1863 let entries = panel
1864 .read(cx)
1865 .acp_history
1866 .read(cx)
1867 .sessions()
1868 .iter()
1869 .take(RECENTLY_UPDATED_MENU_LIMIT)
1870 .cloned()
1871 .collect::<Vec<_>>();
1872
1873 if entries.is_empty() {
1874 return menu;
1875 }
1876
1877 menu = menu.header("Recently Updated");
1878
1879 for entry in entries {
1880 let title = entry
1881 .title
1882 .as_ref()
1883 .filter(|title| !title.is_empty())
1884 .cloned()
1885 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
1886
1887 menu = menu.entry(title, None, {
1888 let panel = panel.downgrade();
1889 let entry = entry.clone();
1890 move |window, cx| {
1891 let entry = entry.clone();
1892 panel
1893 .update(cx, move |this, cx| {
1894 this.load_agent_thread(
1895 entry.session_id.clone(),
1896 entry.cwd.clone(),
1897 entry.title.clone(),
1898 window,
1899 cx,
1900 );
1901 })
1902 .ok();
1903 }
1904 });
1905 }
1906 }
1907 HistoryKind::TextThreads => {
1908 let entries = panel
1909 .read(cx)
1910 .text_thread_store
1911 .read(cx)
1912 .ordered_text_threads()
1913 .take(RECENTLY_UPDATED_MENU_LIMIT)
1914 .cloned()
1915 .collect::<Vec<_>>();
1916
1917 if entries.is_empty() {
1918 return menu;
1919 }
1920
1921 menu = menu.header("Recent Text Threads");
1922
1923 for entry in entries {
1924 let title = if entry.title.is_empty() {
1925 SharedString::new_static(DEFAULT_THREAD_TITLE)
1926 } else {
1927 entry.title.clone()
1928 };
1929
1930 menu = menu.entry(title, None, {
1931 let panel = panel.downgrade();
1932 let entry = entry.clone();
1933 move |window, cx| {
1934 let path = entry.path.clone();
1935 panel
1936 .update(cx, move |this, cx| {
1937 this.open_saved_text_thread(path.clone(), window, cx)
1938 .detach_and_log_err(cx);
1939 })
1940 .ok();
1941 }
1942 });
1943 }
1944 }
1945 }
1946
1947 menu.separator()
1948 }
1949
1950 pub fn selected_agent(&self) -> AgentType {
1951 self.selected_agent.clone()
1952 }
1953
1954 fn subscribe_to_active_thread_view(
1955 server_view: &Entity<ConnectionView>,
1956 window: &mut Window,
1957 cx: &mut Context<Self>,
1958 ) -> Option<Subscription> {
1959 server_view.read(cx).active_thread().cloned().map(|tv| {
1960 cx.subscribe_in(
1961 &tv,
1962 window,
1963 |this, view, event: &AcpThreadViewEvent, window, cx| match event {
1964 AcpThreadViewEvent::FirstSendRequested { content } => {
1965 this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
1966 }
1967 },
1968 )
1969 })
1970 }
1971
1972 pub fn start_thread_in(&self) -> &StartThreadIn {
1973 &self.start_thread_in
1974 }
1975
1976 fn set_start_thread_in(&mut self, action: &StartThreadIn, cx: &mut Context<Self>) {
1977 if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::<AgentV2FeatureFlag>() {
1978 return;
1979 }
1980
1981 let new_target = match *action {
1982 StartThreadIn::LocalProject => StartThreadIn::LocalProject,
1983 StartThreadIn::NewWorktree => {
1984 if !self.project_has_git_repository(cx) {
1985 log::error!(
1986 "set_start_thread_in: cannot use NewWorktree without a git repository"
1987 );
1988 return;
1989 }
1990 if self.project.read(cx).is_via_collab() {
1991 log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
1992 return;
1993 }
1994 StartThreadIn::NewWorktree
1995 }
1996 };
1997 self.start_thread_in = new_target;
1998 self.serialize(cx);
1999 cx.notify();
2000 }
2001
2002 fn selected_external_agent(&self) -> Option<ExternalAgent> {
2003 match &self.selected_agent {
2004 AgentType::NativeAgent => Some(ExternalAgent::NativeAgent),
2005 AgentType::Custom { name } => Some(ExternalAgent::Custom { name: name.clone() }),
2006 AgentType::TextThread => None,
2007 }
2008 }
2009
2010 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
2011 if let Some(extension_store) = ExtensionStore::try_global(cx) {
2012 let (manifests, extensions_dir) = {
2013 let store = extension_store.read(cx);
2014 let installed = store.installed_extensions();
2015 let manifests: Vec<_> = installed
2016 .iter()
2017 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
2018 .collect();
2019 let extensions_dir = paths::extensions_dir().join("installed");
2020 (manifests, extensions_dir)
2021 };
2022
2023 self.project.update(cx, |project, cx| {
2024 project.agent_server_store().update(cx, |store, cx| {
2025 let manifest_refs: Vec<_> = manifests
2026 .iter()
2027 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2028 .collect();
2029 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2030 });
2031 });
2032 }
2033 }
2034
2035 pub fn new_agent_thread_with_external_source_prompt(
2036 &mut self,
2037 external_source_prompt: Option<ExternalSourcePrompt>,
2038 window: &mut Window,
2039 cx: &mut Context<Self>,
2040 ) {
2041 self.external_thread(
2042 None,
2043 None,
2044 None,
2045 None,
2046 external_source_prompt.map(AgentInitialContent::from),
2047 true,
2048 window,
2049 cx,
2050 );
2051 }
2052
2053 pub fn new_agent_thread(
2054 &mut self,
2055 agent: AgentType,
2056 window: &mut Window,
2057 cx: &mut Context<Self>,
2058 ) {
2059 self.new_agent_thread_inner(agent, true, window, cx);
2060 }
2061
2062 fn new_agent_thread_inner(
2063 &mut self,
2064 agent: AgentType,
2065 focus: bool,
2066 window: &mut Window,
2067 cx: &mut Context<Self>,
2068 ) {
2069 match agent {
2070 AgentType::TextThread => {
2071 window.dispatch_action(NewTextThread.boxed_clone(), cx);
2072 }
2073 AgentType::NativeAgent => self.external_thread(
2074 Some(crate::ExternalAgent::NativeAgent),
2075 None,
2076 None,
2077 None,
2078 None,
2079 focus,
2080 window,
2081 cx,
2082 ),
2083 AgentType::Custom { name } => self.external_thread(
2084 Some(crate::ExternalAgent::Custom { name }),
2085 None,
2086 None,
2087 None,
2088 None,
2089 focus,
2090 window,
2091 cx,
2092 ),
2093 }
2094 }
2095
2096 pub fn load_agent_thread(
2097 &mut self,
2098 session_id: acp::SessionId,
2099 cwd: Option<PathBuf>,
2100 title: Option<SharedString>,
2101 window: &mut Window,
2102 cx: &mut Context<Self>,
2103 ) {
2104 self.load_agent_thread_inner(session_id, cwd, title, true, window, cx);
2105 }
2106
2107 fn load_agent_thread_inner(
2108 &mut self,
2109 session_id: acp::SessionId,
2110 cwd: Option<PathBuf>,
2111 title: Option<SharedString>,
2112 focus: bool,
2113 window: &mut Window,
2114 cx: &mut Context<Self>,
2115 ) {
2116 if let Some(server_view) = self.background_threads.remove(&session_id) {
2117 self.set_active_view(ActiveView::AgentThread { server_view }, focus, window, cx);
2118 return;
2119 }
2120
2121 if let ActiveView::AgentThread { server_view } = &self.active_view {
2122 if server_view
2123 .read(cx)
2124 .active_thread()
2125 .map(|t| t.read(cx).id.clone())
2126 == Some(session_id.clone())
2127 {
2128 cx.emit(AgentPanelEvent::ActiveViewChanged);
2129 return;
2130 }
2131 }
2132
2133 if let Some(ActiveView::AgentThread { server_view }) = &self.previous_view {
2134 if server_view
2135 .read(cx)
2136 .active_thread()
2137 .map(|t| t.read(cx).id.clone())
2138 == Some(session_id.clone())
2139 {
2140 let view = self.previous_view.take().unwrap();
2141 self.set_active_view(view, focus, window, cx);
2142 return;
2143 }
2144 }
2145
2146 let Some(agent) = self.selected_external_agent() else {
2147 return;
2148 };
2149 self.external_thread(
2150 Some(agent),
2151 Some(session_id),
2152 cwd,
2153 title,
2154 None,
2155 focus,
2156 window,
2157 cx,
2158 );
2159 }
2160
2161 pub(crate) fn create_external_thread(
2162 &mut self,
2163 server: Rc<dyn AgentServer>,
2164 resume_session_id: Option<acp::SessionId>,
2165 cwd: Option<PathBuf>,
2166 title: Option<SharedString>,
2167 initial_content: Option<AgentInitialContent>,
2168 workspace: WeakEntity<Workspace>,
2169 project: Entity<Project>,
2170 ext_agent: ExternalAgent,
2171 focus: bool,
2172 window: &mut Window,
2173 cx: &mut Context<Self>,
2174 ) {
2175 let selected_agent = AgentType::from(ext_agent);
2176 if self.selected_agent != selected_agent {
2177 self.selected_agent = selected_agent;
2178 self.serialize(cx);
2179 }
2180 let thread_store = server
2181 .clone()
2182 .downcast::<agent::NativeAgentServer>()
2183 .is_some()
2184 .then(|| self.thread_store.clone());
2185
2186 let server_view = cx.new(|cx| {
2187 crate::ConnectionView::new(
2188 server,
2189 resume_session_id,
2190 cwd,
2191 title,
2192 initial_content,
2193 workspace.clone(),
2194 project,
2195 thread_store,
2196 self.prompt_store.clone(),
2197 self.acp_history.clone(),
2198 window,
2199 cx,
2200 )
2201 });
2202
2203 cx.observe(&server_view, |this, server_view, cx| {
2204 let is_active = this
2205 .as_active_server_view()
2206 .is_some_and(|active| active.entity_id() == server_view.entity_id());
2207 if is_active {
2208 cx.emit(AgentPanelEvent::ActiveViewChanged);
2209 this.serialize(cx);
2210 } else {
2211 cx.emit(AgentPanelEvent::BackgroundThreadChanged);
2212 }
2213 cx.notify();
2214 })
2215 .detach();
2216
2217 self.set_active_view(ActiveView::AgentThread { server_view }, focus, window, cx);
2218 }
2219
2220 fn active_thread_has_messages(&self, cx: &App) -> bool {
2221 self.active_agent_thread(cx)
2222 .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2223 }
2224
2225 fn handle_first_send_requested(
2226 &mut self,
2227 thread_view: Entity<ThreadView>,
2228 content: Vec<acp::ContentBlock>,
2229 window: &mut Window,
2230 cx: &mut Context<Self>,
2231 ) {
2232 if self.start_thread_in == StartThreadIn::NewWorktree {
2233 self.handle_worktree_creation_requested(content, window, cx);
2234 } else {
2235 cx.defer_in(window, move |_this, window, cx| {
2236 thread_view.update(cx, |thread_view, cx| {
2237 let editor = thread_view.message_editor.clone();
2238 thread_view.send_impl(editor, window, cx);
2239 });
2240 });
2241 }
2242 }
2243
2244 /// Partitions the project's visible worktrees into git-backed repositories
2245 /// and plain (non-git) paths. Git repos will have worktrees created for
2246 /// them; non-git paths are carried over to the new workspace as-is.
2247 ///
2248 /// When multiple worktrees map to the same repository, the most specific
2249 /// match wins (deepest work directory path), with a deterministic
2250 /// tie-break on entity id. Each repository appears at most once.
2251 fn classify_worktrees(
2252 &self,
2253 cx: &App,
2254 ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2255 let project = &self.project;
2256 let repositories = project.read(cx).repositories(cx).clone();
2257 let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2258 let mut non_git_paths: Vec<PathBuf> = Vec::new();
2259 let mut seen_repo_ids = std::collections::HashSet::new();
2260
2261 for worktree in project.read(cx).visible_worktrees(cx) {
2262 let wt_path = worktree.read(cx).abs_path();
2263
2264 let matching_repo = repositories
2265 .iter()
2266 .filter_map(|(id, repo)| {
2267 let work_dir = repo.read(cx).work_directory_abs_path.clone();
2268 if wt_path.starts_with(work_dir.as_ref())
2269 || work_dir.starts_with(wt_path.as_ref())
2270 {
2271 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2272 } else {
2273 None
2274 }
2275 })
2276 .max_by(
2277 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2278 left_depth
2279 .cmp(right_depth)
2280 .then_with(|| left_id.cmp(right_id))
2281 },
2282 );
2283
2284 if let Some((id, repo, _)) = matching_repo {
2285 if seen_repo_ids.insert(id) {
2286 git_repos.push(repo);
2287 }
2288 } else {
2289 non_git_paths.push(wt_path.to_path_buf());
2290 }
2291 }
2292
2293 (git_repos, non_git_paths)
2294 }
2295
2296 /// Kicks off an async git-worktree creation for each repository. Returns:
2297 ///
2298 /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2299 /// receiver resolves once the git worktree command finishes.
2300 /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2301 /// later to remap open editor tabs into the new workspace.
2302 fn start_worktree_creations(
2303 git_repos: &[Entity<project::git_store::Repository>],
2304 branch_name: &str,
2305 worktree_directory_setting: &str,
2306 cx: &mut Context<Self>,
2307 ) -> Result<(
2308 Vec<(
2309 Entity<project::git_store::Repository>,
2310 PathBuf,
2311 futures::channel::oneshot::Receiver<Result<()>>,
2312 )>,
2313 Vec<(PathBuf, PathBuf)>,
2314 )> {
2315 let mut creation_infos = Vec::new();
2316 let mut path_remapping = Vec::new();
2317
2318 for repo in git_repos {
2319 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2320 let original_repo = repo.original_repo_abs_path.clone();
2321 let directory =
2322 validate_worktree_directory(&original_repo, worktree_directory_setting)?;
2323 let new_path = directory.join(branch_name);
2324 let receiver = repo.create_worktree(branch_name.to_string(), directory, None);
2325 let work_dir = repo.work_directory_abs_path.clone();
2326 anyhow::Ok((work_dir, new_path, receiver))
2327 })?;
2328 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2329 creation_infos.push((repo.clone(), new_path, receiver));
2330 }
2331
2332 Ok((creation_infos, path_remapping))
2333 }
2334
2335 /// Waits for every in-flight worktree creation to complete. If any
2336 /// creation fails, all successfully-created worktrees are rolled back
2337 /// (removed) so the project isn't left in a half-migrated state.
2338 async fn await_and_rollback_on_failure(
2339 creation_infos: Vec<(
2340 Entity<project::git_store::Repository>,
2341 PathBuf,
2342 futures::channel::oneshot::Receiver<Result<()>>,
2343 )>,
2344 cx: &mut AsyncWindowContext,
2345 ) -> Result<Vec<PathBuf>> {
2346 let mut created_paths: Vec<PathBuf> = Vec::new();
2347 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2348 Vec::new();
2349 let mut first_error: Option<anyhow::Error> = None;
2350
2351 for (repo, new_path, receiver) in creation_infos {
2352 match receiver.await {
2353 Ok(Ok(())) => {
2354 created_paths.push(new_path.clone());
2355 repos_and_paths.push((repo, new_path));
2356 }
2357 Ok(Err(err)) => {
2358 if first_error.is_none() {
2359 first_error = Some(err);
2360 }
2361 }
2362 Err(_canceled) => {
2363 if first_error.is_none() {
2364 first_error = Some(anyhow!("Worktree creation was canceled"));
2365 }
2366 }
2367 }
2368 }
2369
2370 let Some(err) = first_error else {
2371 return Ok(created_paths);
2372 };
2373
2374 // Rollback all successfully created worktrees
2375 let mut rollback_receivers = Vec::new();
2376 for (rollback_repo, rollback_path) in &repos_and_paths {
2377 if let Ok(receiver) = cx.update(|_, cx| {
2378 rollback_repo.update(cx, |repo, _cx| {
2379 repo.remove_worktree(rollback_path.clone(), true)
2380 })
2381 }) {
2382 rollback_receivers.push((rollback_path.clone(), receiver));
2383 }
2384 }
2385 let mut rollback_failures: Vec<String> = Vec::new();
2386 for (path, receiver) in rollback_receivers {
2387 match receiver.await {
2388 Ok(Ok(())) => {}
2389 Ok(Err(rollback_err)) => {
2390 log::error!(
2391 "failed to rollback worktree at {}: {rollback_err}",
2392 path.display()
2393 );
2394 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2395 }
2396 Err(rollback_err) => {
2397 log::error!(
2398 "failed to rollback worktree at {}: {rollback_err}",
2399 path.display()
2400 );
2401 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2402 }
2403 }
2404 }
2405 let mut error_message = format!("Failed to create worktree: {err}");
2406 if !rollback_failures.is_empty() {
2407 error_message.push_str("\n\nFailed to clean up: ");
2408 error_message.push_str(&rollback_failures.join(", "));
2409 }
2410 Err(anyhow!(error_message))
2411 }
2412
2413 fn set_worktree_creation_error(
2414 &mut self,
2415 message: SharedString,
2416 window: &mut Window,
2417 cx: &mut Context<Self>,
2418 ) {
2419 self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
2420 if matches!(self.active_view, ActiveView::Uninitialized) {
2421 let selected_agent = self.selected_agent.clone();
2422 self.new_agent_thread(selected_agent, window, cx);
2423 }
2424 cx.notify();
2425 }
2426
2427 fn handle_worktree_creation_requested(
2428 &mut self,
2429 content: Vec<acp::ContentBlock>,
2430 window: &mut Window,
2431 cx: &mut Context<Self>,
2432 ) {
2433 if matches!(
2434 self.worktree_creation_status,
2435 Some(WorktreeCreationStatus::Creating)
2436 ) {
2437 return;
2438 }
2439
2440 self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
2441 cx.notify();
2442
2443 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
2444
2445 if git_repos.is_empty() {
2446 self.set_worktree_creation_error(
2447 "No git repositories found in the project".into(),
2448 window,
2449 cx,
2450 );
2451 return;
2452 }
2453
2454 // Kick off branch listing as early as possible so it can run
2455 // concurrently with the remaining synchronous setup work.
2456 let branch_receivers: Vec<_> = git_repos
2457 .iter()
2458 .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
2459 .collect();
2460
2461 let worktree_directory_setting = ProjectSettings::get_global(cx)
2462 .git
2463 .worktree_directory
2464 .clone();
2465
2466 let (dock_structure, open_file_paths) = self
2467 .workspace
2468 .upgrade()
2469 .map(|workspace| {
2470 let dock_structure = workspace.read(cx).capture_dock_state(window, cx);
2471 let open_file_paths = workspace.read(cx).open_item_abs_paths(cx);
2472 (dock_structure, open_file_paths)
2473 })
2474 .unwrap_or_default();
2475
2476 let workspace = self.workspace.clone();
2477 let window_handle = window
2478 .window_handle()
2479 .downcast::<workspace::MultiWorkspace>();
2480
2481 let task = cx.spawn_in(window, async move |this, cx| {
2482 // Await the branch listings we kicked off earlier.
2483 let mut existing_branches = Vec::new();
2484 for result in futures::future::join_all(branch_receivers).await {
2485 match result {
2486 Ok(Ok(branches)) => {
2487 for branch in branches {
2488 existing_branches.push(branch.name().to_string());
2489 }
2490 }
2491 Ok(Err(err)) => {
2492 Err::<(), _>(err).log_err();
2493 }
2494 Err(_) => {}
2495 }
2496 }
2497
2498 let existing_branch_refs: Vec<&str> =
2499 existing_branches.iter().map(|s| s.as_str()).collect();
2500 let mut rng = rand::rng();
2501 let branch_name =
2502 match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
2503 Some(name) => name,
2504 None => {
2505 this.update_in(cx, |this, window, cx| {
2506 this.set_worktree_creation_error(
2507 "Failed to generate a branch name: all typewriter names are taken"
2508 .into(),
2509 window,
2510 cx,
2511 );
2512 })?;
2513 return anyhow::Ok(());
2514 }
2515 };
2516
2517 let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
2518 Self::start_worktree_creations(
2519 &git_repos,
2520 &branch_name,
2521 &worktree_directory_setting,
2522 cx,
2523 )
2524 }) {
2525 Ok(Ok(result)) => result,
2526 Ok(Err(err)) | Err(err) => {
2527 this.update_in(cx, |this, window, cx| {
2528 this.set_worktree_creation_error(
2529 format!("Failed to validate worktree directory: {err}").into(),
2530 window,
2531 cx,
2532 );
2533 })
2534 .log_err();
2535 return anyhow::Ok(());
2536 }
2537 };
2538
2539 let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
2540 {
2541 Ok(paths) => paths,
2542 Err(err) => {
2543 this.update_in(cx, |this, window, cx| {
2544 this.set_worktree_creation_error(format!("{err}").into(), window, cx);
2545 })?;
2546 return anyhow::Ok(());
2547 }
2548 };
2549
2550 let mut all_paths = created_paths;
2551 let has_non_git = !non_git_paths.is_empty();
2552 all_paths.extend(non_git_paths.iter().cloned());
2553
2554 let app_state = match workspace.upgrade() {
2555 Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
2556 None => {
2557 this.update_in(cx, |this, window, cx| {
2558 this.set_worktree_creation_error(
2559 "Workspace no longer available".into(),
2560 window,
2561 cx,
2562 );
2563 })?;
2564 return anyhow::Ok(());
2565 }
2566 };
2567
2568 let this_for_error = this.clone();
2569 if let Err(err) = Self::setup_new_workspace(
2570 this,
2571 all_paths,
2572 app_state,
2573 window_handle,
2574 dock_structure,
2575 open_file_paths,
2576 path_remapping,
2577 non_git_paths,
2578 has_non_git,
2579 content,
2580 cx,
2581 )
2582 .await
2583 {
2584 this_for_error
2585 .update_in(cx, |this, window, cx| {
2586 this.set_worktree_creation_error(
2587 format!("Failed to set up workspace: {err}").into(),
2588 window,
2589 cx,
2590 );
2591 })
2592 .log_err();
2593 }
2594 anyhow::Ok(())
2595 });
2596
2597 self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move {
2598 task.await.log_err();
2599 }));
2600 }
2601
2602 async fn setup_new_workspace(
2603 this: WeakEntity<Self>,
2604 all_paths: Vec<PathBuf>,
2605 app_state: Arc<workspace::AppState>,
2606 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
2607 dock_structure: workspace::DockStructure,
2608 open_file_paths: Vec<PathBuf>,
2609 path_remapping: Vec<(PathBuf, PathBuf)>,
2610 non_git_paths: Vec<PathBuf>,
2611 has_non_git: bool,
2612 content: Vec<acp::ContentBlock>,
2613 cx: &mut AsyncWindowContext,
2614 ) -> Result<()> {
2615 let init: Option<
2616 Box<dyn FnOnce(&mut Workspace, &mut Window, &mut gpui::Context<Workspace>) + Send>,
2617 > = Some(Box::new(move |workspace, window, cx| {
2618 workspace.set_dock_structure(dock_structure, window, cx);
2619 }));
2620
2621 let (new_window_handle, _) = cx
2622 .update(|_window, cx| {
2623 Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx)
2624 })?
2625 .await?;
2626
2627 let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| {
2628 let workspaces = multi_workspace.workspaces();
2629 workspaces.last().cloned()
2630 })?;
2631
2632 let Some(new_workspace) = new_workspace else {
2633 anyhow::bail!("New workspace was not added to MultiWorkspace");
2634 };
2635
2636 let panels_task = new_window_handle.update(cx, |_, _, cx| {
2637 new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task())
2638 })?;
2639 if let Some(task) = panels_task {
2640 task.await.log_err();
2641 }
2642
2643 let initial_content = AgentInitialContent::ContentBlock {
2644 blocks: content,
2645 auto_submit: true,
2646 };
2647
2648 new_window_handle.update(cx, |_multi_workspace, window, cx| {
2649 new_workspace.update(cx, |workspace, cx| {
2650 if has_non_git {
2651 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
2652 workspace.show_toast(
2653 workspace::Toast::new(
2654 toast_id,
2655 "Some project folders are not git repositories. \
2656 They were included as-is without creating a worktree.",
2657 ),
2658 cx,
2659 );
2660 }
2661
2662 let remapped_paths: Vec<PathBuf> = open_file_paths
2663 .iter()
2664 .filter_map(|original_path| {
2665 let best_match = path_remapping
2666 .iter()
2667 .filter_map(|(old_root, new_root)| {
2668 original_path.strip_prefix(old_root).ok().map(|relative| {
2669 (old_root.components().count(), new_root.join(relative))
2670 })
2671 })
2672 .max_by_key(|(depth, _)| *depth);
2673
2674 if let Some((_, remapped_path)) = best_match {
2675 return Some(remapped_path);
2676 }
2677
2678 for non_git in &non_git_paths {
2679 if original_path.starts_with(non_git) {
2680 return Some(original_path.clone());
2681 }
2682 }
2683 None
2684 })
2685 .collect();
2686
2687 if !remapped_paths.is_empty() {
2688 workspace
2689 .open_paths(
2690 remapped_paths,
2691 workspace::OpenOptions::default(),
2692 None,
2693 window,
2694 cx,
2695 )
2696 .detach();
2697 }
2698
2699 workspace.focus_panel::<AgentPanel>(window, cx);
2700 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2701 panel.update(cx, |panel, cx| {
2702 panel.external_thread(
2703 None,
2704 None,
2705 None,
2706 None,
2707 Some(initial_content),
2708 true,
2709 window,
2710 cx,
2711 );
2712 });
2713 }
2714 });
2715 })?;
2716
2717 new_window_handle.update(cx, |multi_workspace, _window, cx| {
2718 multi_workspace.activate(new_workspace.clone(), cx);
2719 })?;
2720
2721 this.update_in(cx, |this, _window, cx| {
2722 this.worktree_creation_status = None;
2723 cx.notify();
2724 })?;
2725
2726 anyhow::Ok(())
2727 }
2728}
2729
2730impl Focusable for AgentPanel {
2731 fn focus_handle(&self, cx: &App) -> FocusHandle {
2732 match &self.active_view {
2733 ActiveView::Uninitialized => self.focus_handle.clone(),
2734 ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
2735 ActiveView::History { kind } => match kind {
2736 HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
2737 HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
2738 },
2739 ActiveView::TextThread {
2740 text_thread_editor, ..
2741 } => text_thread_editor.focus_handle(cx),
2742 ActiveView::Configuration => {
2743 if let Some(configuration) = self.configuration.as_ref() {
2744 configuration.focus_handle(cx)
2745 } else {
2746 self.focus_handle.clone()
2747 }
2748 }
2749 }
2750 }
2751}
2752
2753fn agent_panel_dock_position(cx: &App) -> DockPosition {
2754 AgentSettings::get_global(cx).dock.into()
2755}
2756
2757pub enum AgentPanelEvent {
2758 ActiveViewChanged,
2759 ThreadFocused,
2760 BackgroundThreadChanged,
2761}
2762
2763impl EventEmitter<PanelEvent> for AgentPanel {}
2764impl EventEmitter<AgentPanelEvent> for AgentPanel {}
2765
2766impl Panel for AgentPanel {
2767 fn persistent_name() -> &'static str {
2768 "AgentPanel"
2769 }
2770
2771 fn panel_key() -> &'static str {
2772 AGENT_PANEL_KEY
2773 }
2774
2775 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
2776 agent_panel_dock_position(cx)
2777 }
2778
2779 fn position_is_valid(&self, position: DockPosition) -> bool {
2780 position != DockPosition::Bottom
2781 }
2782
2783 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2784 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
2785 settings
2786 .agent
2787 .get_or_insert_default()
2788 .set_dock(position.into());
2789 });
2790 }
2791
2792 fn size(&self, window: &Window, cx: &App) -> Pixels {
2793 let settings = AgentSettings::get_global(cx);
2794 match self.position(window, cx) {
2795 DockPosition::Left | DockPosition::Right => {
2796 self.width.unwrap_or(settings.default_width)
2797 }
2798 DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
2799 }
2800 }
2801
2802 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
2803 match self.position(window, cx) {
2804 DockPosition::Left | DockPosition::Right => self.width = size,
2805 DockPosition::Bottom => self.height = size,
2806 }
2807 self.serialize(cx);
2808 cx.notify();
2809 }
2810
2811 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
2812 if active
2813 && matches!(self.active_view, ActiveView::Uninitialized)
2814 && !matches!(
2815 self.worktree_creation_status,
2816 Some(WorktreeCreationStatus::Creating)
2817 )
2818 {
2819 let selected_agent = self.selected_agent.clone();
2820 self.new_agent_thread_inner(selected_agent, false, window, cx);
2821 }
2822 }
2823
2824 fn remote_id() -> Option<proto::PanelId> {
2825 Some(proto::PanelId::AssistantPanel)
2826 }
2827
2828 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
2829 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
2830 }
2831
2832 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2833 Some("Agent Panel")
2834 }
2835
2836 fn toggle_action(&self) -> Box<dyn Action> {
2837 Box::new(ToggleFocus)
2838 }
2839
2840 fn activation_priority(&self) -> u32 {
2841 3
2842 }
2843
2844 fn enabled(&self, cx: &App) -> bool {
2845 AgentSettings::get_global(cx).enabled(cx)
2846 }
2847
2848 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
2849 self.zoomed
2850 }
2851
2852 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
2853 self.zoomed = zoomed;
2854 cx.notify();
2855 }
2856}
2857
2858impl AgentPanel {
2859 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
2860 const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
2861
2862 let content = match &self.active_view {
2863 ActiveView::AgentThread { server_view } => {
2864 let is_generating_title = server_view
2865 .read(cx)
2866 .as_native_thread(cx)
2867 .map_or(false, |t| t.read(cx).is_generating_title());
2868
2869 if let Some(title_editor) = server_view
2870 .read(cx)
2871 .parent_thread(cx)
2872 .map(|r| r.read(cx).title_editor.clone())
2873 {
2874 let container = div()
2875 .w_full()
2876 .on_action({
2877 let thread_view = server_view.downgrade();
2878 move |_: &menu::Confirm, window, cx| {
2879 if let Some(thread_view) = thread_view.upgrade() {
2880 thread_view.focus_handle(cx).focus(window, cx);
2881 }
2882 }
2883 })
2884 .on_action({
2885 let thread_view = server_view.downgrade();
2886 move |_: &editor::actions::Cancel, window, cx| {
2887 if let Some(thread_view) = thread_view.upgrade() {
2888 thread_view.focus_handle(cx).focus(window, cx);
2889 }
2890 }
2891 })
2892 .child(title_editor);
2893
2894 if is_generating_title {
2895 container
2896 .with_animation(
2897 "generating_title",
2898 Animation::new(Duration::from_secs(2))
2899 .repeat()
2900 .with_easing(pulsating_between(0.4, 0.8)),
2901 |div, delta| div.opacity(delta),
2902 )
2903 .into_any_element()
2904 } else {
2905 container.into_any_element()
2906 }
2907 } else {
2908 Label::new(server_view.read(cx).title(cx))
2909 .color(Color::Muted)
2910 .truncate()
2911 .into_any_element()
2912 }
2913 }
2914 ActiveView::TextThread {
2915 title_editor,
2916 text_thread_editor,
2917 ..
2918 } => {
2919 let summary = text_thread_editor.read(cx).text_thread().read(cx).summary();
2920
2921 match summary {
2922 TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT)
2923 .color(Color::Muted)
2924 .truncate()
2925 .into_any_element(),
2926 TextThreadSummary::Content(summary) => {
2927 if summary.done {
2928 div()
2929 .w_full()
2930 .child(title_editor.clone())
2931 .into_any_element()
2932 } else {
2933 Label::new(LOADING_SUMMARY_PLACEHOLDER)
2934 .truncate()
2935 .color(Color::Muted)
2936 .with_animation(
2937 "generating_title",
2938 Animation::new(Duration::from_secs(2))
2939 .repeat()
2940 .with_easing(pulsating_between(0.4, 0.8)),
2941 |label, delta| label.alpha(delta),
2942 )
2943 .into_any_element()
2944 }
2945 }
2946 TextThreadSummary::Error => h_flex()
2947 .w_full()
2948 .child(title_editor.clone())
2949 .child(
2950 IconButton::new("retry-summary-generation", IconName::RotateCcw)
2951 .icon_size(IconSize::Small)
2952 .on_click({
2953 let text_thread_editor = text_thread_editor.clone();
2954 move |_, _window, cx| {
2955 text_thread_editor.update(cx, |text_thread_editor, cx| {
2956 text_thread_editor.regenerate_summary(cx);
2957 });
2958 }
2959 })
2960 .tooltip(move |_window, cx| {
2961 cx.new(|_| {
2962 Tooltip::new("Failed to generate title")
2963 .meta("Click to try again")
2964 })
2965 .into()
2966 }),
2967 )
2968 .into_any_element(),
2969 }
2970 }
2971 ActiveView::History { kind } => {
2972 let title = match kind {
2973 HistoryKind::AgentThreads => "History",
2974 HistoryKind::TextThreads => "Text Thread History",
2975 };
2976 Label::new(title).truncate().into_any_element()
2977 }
2978 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
2979 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
2980 };
2981
2982 h_flex()
2983 .key_context("TitleEditor")
2984 .id("TitleEditor")
2985 .flex_grow()
2986 .w_full()
2987 .max_w_full()
2988 .overflow_x_scroll()
2989 .child(content)
2990 .into_any()
2991 }
2992
2993 fn handle_regenerate_thread_title(thread_view: Entity<ConnectionView>, cx: &mut App) {
2994 thread_view.update(cx, |thread_view, cx| {
2995 if let Some(thread) = thread_view.as_native_thread(cx) {
2996 thread.update(cx, |thread, cx| {
2997 thread.generate_title(cx);
2998 });
2999 }
3000 });
3001 }
3002
3003 fn handle_regenerate_text_thread_title(
3004 text_thread_editor: Entity<TextThreadEditor>,
3005 cx: &mut App,
3006 ) {
3007 text_thread_editor.update(cx, |text_thread_editor, cx| {
3008 text_thread_editor.regenerate_summary(cx);
3009 });
3010 }
3011
3012 fn render_panel_options_menu(
3013 &self,
3014 window: &mut Window,
3015 cx: &mut Context<Self>,
3016 ) -> impl IntoElement {
3017 let focus_handle = self.focus_handle(cx);
3018
3019 let full_screen_label = if self.is_zoomed(window, cx) {
3020 "Disable Full Screen"
3021 } else {
3022 "Enable Full Screen"
3023 };
3024
3025 let text_thread_view = match &self.active_view {
3026 ActiveView::TextThread {
3027 text_thread_editor, ..
3028 } => Some(text_thread_editor.clone()),
3029 _ => None,
3030 };
3031 let text_thread_with_messages = match &self.active_view {
3032 ActiveView::TextThread {
3033 text_thread_editor, ..
3034 } => text_thread_editor
3035 .read(cx)
3036 .text_thread()
3037 .read(cx)
3038 .messages(cx)
3039 .any(|message| message.role == language_model::Role::Assistant),
3040 _ => false,
3041 };
3042
3043 let thread_view = match &self.active_view {
3044 ActiveView::AgentThread { server_view } => Some(server_view.clone()),
3045 _ => None,
3046 };
3047 let thread_with_messages = match &self.active_view {
3048 ActiveView::AgentThread { server_view } => {
3049 server_view.read(cx).has_user_submitted_prompt(cx)
3050 }
3051 _ => false,
3052 };
3053 let has_auth_methods = match &self.active_view {
3054 ActiveView::AgentThread { server_view } => server_view.read(cx).has_auth_methods(),
3055 _ => false,
3056 };
3057
3058 PopoverMenu::new("agent-options-menu")
3059 .trigger_with_tooltip(
3060 IconButton::new("agent-options-menu", IconName::Ellipsis)
3061 .icon_size(IconSize::Small),
3062 {
3063 let focus_handle = focus_handle.clone();
3064 move |_window, cx| {
3065 Tooltip::for_action_in(
3066 "Toggle Agent Menu",
3067 &ToggleOptionsMenu,
3068 &focus_handle,
3069 cx,
3070 )
3071 }
3072 },
3073 )
3074 .anchor(Corner::TopRight)
3075 .with_handle(self.agent_panel_menu_handle.clone())
3076 .menu({
3077 move |window, cx| {
3078 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3079 menu = menu.context(focus_handle.clone());
3080
3081 if thread_with_messages | text_thread_with_messages {
3082 menu = menu.header("Current Thread");
3083
3084 if let Some(text_thread_view) = text_thread_view.as_ref() {
3085 menu = menu
3086 .entry("Regenerate Thread Title", None, {
3087 let text_thread_view = text_thread_view.clone();
3088 move |_, cx| {
3089 Self::handle_regenerate_text_thread_title(
3090 text_thread_view.clone(),
3091 cx,
3092 );
3093 }
3094 })
3095 .separator();
3096 }
3097
3098 if let Some(thread_view) = thread_view.as_ref() {
3099 menu = menu
3100 .entry("Regenerate Thread Title", None, {
3101 let thread_view = thread_view.clone();
3102 move |_, cx| {
3103 Self::handle_regenerate_thread_title(
3104 thread_view.clone(),
3105 cx,
3106 );
3107 }
3108 })
3109 .separator();
3110 }
3111 }
3112
3113 menu = menu
3114 .header("MCP Servers")
3115 .action(
3116 "View Server Extensions",
3117 Box::new(zed_actions::Extensions {
3118 category_filter: Some(
3119 zed_actions::ExtensionCategoryFilter::ContextServers,
3120 ),
3121 id: None,
3122 }),
3123 )
3124 .action("Add Custom Server…", Box::new(AddContextServer))
3125 .separator()
3126 .action("Rules", Box::new(OpenRulesLibrary::default()))
3127 .action("Profiles", Box::new(ManageProfiles::default()))
3128 .action("Settings", Box::new(OpenSettings))
3129 .separator()
3130 .action(full_screen_label, Box::new(ToggleZoom));
3131
3132 if has_auth_methods {
3133 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3134 }
3135
3136 menu
3137 }))
3138 }
3139 })
3140 }
3141
3142 fn render_recent_entries_menu(
3143 &self,
3144 icon: IconName,
3145 corner: Corner,
3146 cx: &mut Context<Self>,
3147 ) -> impl IntoElement {
3148 let focus_handle = self.focus_handle(cx);
3149
3150 PopoverMenu::new("agent-nav-menu")
3151 .trigger_with_tooltip(
3152 IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
3153 {
3154 move |_window, cx| {
3155 Tooltip::for_action_in(
3156 "Toggle Recently Updated Threads",
3157 &ToggleNavigationMenu,
3158 &focus_handle,
3159 cx,
3160 )
3161 }
3162 },
3163 )
3164 .anchor(corner)
3165 .with_handle(self.agent_navigation_menu_handle.clone())
3166 .menu({
3167 let menu = self.agent_navigation_menu.clone();
3168 move |window, cx| {
3169 telemetry::event!("View Thread History Clicked");
3170
3171 if let Some(menu) = menu.as_ref() {
3172 menu.update(cx, |_, cx| {
3173 cx.defer_in(window, |menu, window, cx| {
3174 menu.rebuild(window, cx);
3175 });
3176 })
3177 }
3178 menu.clone()
3179 }
3180 })
3181 }
3182
3183 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3184 let focus_handle = self.focus_handle(cx);
3185
3186 IconButton::new("go-back", IconName::ArrowLeft)
3187 .icon_size(IconSize::Small)
3188 .on_click(cx.listener(|this, _, window, cx| {
3189 this.go_back(&workspace::GoBack, window, cx);
3190 }))
3191 .tooltip({
3192 move |_window, cx| {
3193 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3194 }
3195 })
3196 }
3197
3198 fn project_has_git_repository(&self, cx: &App) -> bool {
3199 !self.project.read(cx).repositories(cx).is_empty()
3200 }
3201
3202 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3203 let focus_handle = self.focus_handle(cx);
3204 let has_git_repo = self.project_has_git_repository(cx);
3205 let is_via_collab = self.project.read(cx).is_via_collab();
3206
3207 let is_creating = matches!(
3208 self.worktree_creation_status,
3209 Some(WorktreeCreationStatus::Creating)
3210 );
3211
3212 let current_target = self.start_thread_in;
3213 let trigger_label = self.start_thread_in.label();
3214
3215 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3216 IconName::ChevronUp
3217 } else {
3218 IconName::ChevronDown
3219 };
3220
3221 let trigger_button = Button::new("thread-target-trigger", trigger_label)
3222 .icon(icon)
3223 .icon_size(IconSize::XSmall)
3224 .icon_position(IconPosition::End)
3225 .icon_color(Color::Muted)
3226 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
3227 .disabled(is_creating);
3228
3229 let dock_position = AgentSettings::get_global(cx).dock;
3230 let documentation_side = match dock_position {
3231 settings::DockPosition::Left => DocumentationSide::Right,
3232 settings::DockPosition::Bottom | settings::DockPosition::Right => {
3233 DocumentationSide::Left
3234 }
3235 };
3236
3237 PopoverMenu::new("thread-target-selector")
3238 .trigger_with_tooltip(trigger_button, {
3239 move |_window, cx| {
3240 Tooltip::for_action_in(
3241 "Start Thread In…",
3242 &ToggleStartThreadInSelector,
3243 &focus_handle,
3244 cx,
3245 )
3246 }
3247 })
3248 .menu(move |window, cx| {
3249 let is_local_selected = current_target == StartThreadIn::LocalProject;
3250 let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
3251
3252 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
3253 let new_worktree_disabled = !has_git_repo || is_via_collab;
3254
3255 menu.header("Start Thread In…")
3256 .item(
3257 ContextMenuEntry::new("Current Project")
3258 .toggleable(IconPosition::End, is_local_selected)
3259 .handler(|window, cx| {
3260 window
3261 .dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
3262 }),
3263 )
3264 .item({
3265 let entry = ContextMenuEntry::new("New Worktree")
3266 .toggleable(IconPosition::End, is_new_worktree_selected)
3267 .disabled(new_worktree_disabled)
3268 .handler(|window, cx| {
3269 window
3270 .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx);
3271 });
3272
3273 if new_worktree_disabled {
3274 entry.documentation_aside(documentation_side, move |_| {
3275 let reason = if !has_git_repo {
3276 "No git repository found in this project."
3277 } else {
3278 "Not available for remote/collab projects yet."
3279 };
3280 Label::new(reason)
3281 .color(Color::Muted)
3282 .size(LabelSize::Small)
3283 .into_any_element()
3284 })
3285 } else {
3286 entry
3287 }
3288 })
3289 }))
3290 })
3291 .with_handle(self.start_thread_in_menu_handle.clone())
3292 .anchor(Corner::TopLeft)
3293 .offset(gpui::Point {
3294 x: px(1.0),
3295 y: px(1.0),
3296 })
3297 }
3298
3299 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3300 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3301 let focus_handle = self.focus_handle(cx);
3302
3303 let (selected_agent_custom_icon, selected_agent_label) =
3304 if let AgentType::Custom { name, .. } = &self.selected_agent {
3305 let store = agent_server_store.read(cx);
3306 let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
3307
3308 let label = store
3309 .agent_display_name(&ExternalAgentServerName(name.clone()))
3310 .unwrap_or_else(|| self.selected_agent.label());
3311 (icon, label)
3312 } else {
3313 (None, self.selected_agent.label())
3314 };
3315
3316 let active_thread = match &self.active_view {
3317 ActiveView::AgentThread { server_view } => server_view.read(cx).as_native_thread(cx),
3318 ActiveView::Uninitialized
3319 | ActiveView::TextThread { .. }
3320 | ActiveView::History { .. }
3321 | ActiveView::Configuration => None,
3322 };
3323
3324 let new_thread_menu_builder: Rc<
3325 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3326 > = {
3327 let selected_agent = self.selected_agent.clone();
3328 let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type;
3329
3330 let workspace = self.workspace.clone();
3331 let is_via_collab = workspace
3332 .update(cx, |workspace, cx| {
3333 workspace.project().read(cx).is_via_collab()
3334 })
3335 .unwrap_or_default();
3336
3337 let focus_handle = focus_handle.clone();
3338 let agent_server_store = agent_server_store;
3339
3340 Rc::new(move |window, cx| {
3341 telemetry::event!("New Thread Clicked");
3342
3343 let active_thread = active_thread.clone();
3344 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3345 menu.context(focus_handle.clone())
3346 .when_some(active_thread, |this, active_thread| {
3347 let thread = active_thread.read(cx);
3348
3349 if !thread.is_empty() {
3350 let session_id = thread.id().clone();
3351 this.item(
3352 ContextMenuEntry::new("New From Summary")
3353 .icon(IconName::ThreadFromSummary)
3354 .icon_color(Color::Muted)
3355 .handler(move |window, cx| {
3356 window.dispatch_action(
3357 Box::new(NewNativeAgentThreadFromSummary {
3358 from_session_id: session_id.clone(),
3359 }),
3360 cx,
3361 );
3362 }),
3363 )
3364 } else {
3365 this
3366 }
3367 })
3368 .item(
3369 ContextMenuEntry::new("Zed Agent")
3370 .when(
3371 is_agent_selected(AgentType::NativeAgent)
3372 | is_agent_selected(AgentType::TextThread),
3373 |this| {
3374 this.action(Box::new(NewExternalAgentThread {
3375 agent: None,
3376 }))
3377 },
3378 )
3379 .icon(IconName::ZedAgent)
3380 .icon_color(Color::Muted)
3381 .handler({
3382 let workspace = workspace.clone();
3383 move |window, cx| {
3384 if let Some(workspace) = workspace.upgrade() {
3385 workspace.update(cx, |workspace, cx| {
3386 if let Some(panel) =
3387 workspace.panel::<AgentPanel>(cx)
3388 {
3389 panel.update(cx, |panel, cx| {
3390 panel.new_agent_thread(
3391 AgentType::NativeAgent,
3392 window,
3393 cx,
3394 );
3395 });
3396 }
3397 });
3398 }
3399 }
3400 }),
3401 )
3402 .item(
3403 ContextMenuEntry::new("Text Thread")
3404 .action(NewTextThread.boxed_clone())
3405 .icon(IconName::TextThread)
3406 .icon_color(Color::Muted)
3407 .handler({
3408 let workspace = workspace.clone();
3409 move |window, cx| {
3410 if let Some(workspace) = workspace.upgrade() {
3411 workspace.update(cx, |workspace, cx| {
3412 if let Some(panel) =
3413 workspace.panel::<AgentPanel>(cx)
3414 {
3415 panel.update(cx, |panel, cx| {
3416 panel.new_agent_thread(
3417 AgentType::TextThread,
3418 window,
3419 cx,
3420 );
3421 });
3422 }
3423 });
3424 }
3425 }
3426 }),
3427 )
3428 .separator()
3429 .header("External Agents")
3430 .map(|mut menu| {
3431 let agent_server_store = agent_server_store.read(cx);
3432 let registry_store =
3433 project::AgentRegistryStore::try_global(cx);
3434 let registry_store_ref =
3435 registry_store.as_ref().map(|s| s.read(cx));
3436
3437 struct AgentMenuItem {
3438 id: ExternalAgentServerName,
3439 display_name: SharedString,
3440 }
3441
3442 let agent_items = agent_server_store
3443 .external_agents()
3444 .map(|name| {
3445 let display_name = agent_server_store
3446 .agent_display_name(name)
3447 .or_else(|| {
3448 registry_store_ref
3449 .as_ref()
3450 .and_then(|store| store.agent(name.0.as_ref()))
3451 .map(|a| a.name().clone())
3452 })
3453 .unwrap_or_else(|| name.0.clone());
3454 AgentMenuItem {
3455 id: name.clone(),
3456 display_name,
3457 }
3458 })
3459 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3460 .collect::<Vec<_>>();
3461
3462 for item in &agent_items {
3463 let mut entry =
3464 ContextMenuEntry::new(item.display_name.clone());
3465
3466 let icon_path = agent_server_store
3467 .agent_icon(&item.id)
3468 .or_else(|| {
3469 registry_store_ref
3470 .as_ref()
3471 .and_then(|store| store.agent(item.id.0.as_str()))
3472 .and_then(|a| a.icon_path().cloned())
3473 });
3474
3475 if let Some(icon_path) = icon_path {
3476 entry = entry.custom_icon_svg(icon_path);
3477 } else {
3478 entry = entry.icon(IconName::Sparkle);
3479 }
3480
3481 entry = entry
3482 .when(
3483 is_agent_selected(AgentType::Custom {
3484 name: item.id.0.clone(),
3485 }),
3486 |this| {
3487 this.action(Box::new(
3488 NewExternalAgentThread { agent: None },
3489 ))
3490 },
3491 )
3492 .icon_color(Color::Muted)
3493 .disabled(is_via_collab)
3494 .handler({
3495 let workspace = workspace.clone();
3496 let agent_id = item.id.clone();
3497 move |window, cx| {
3498 if let Some(workspace) = workspace.upgrade() {
3499 workspace.update(cx, |workspace, cx| {
3500 if let Some(panel) =
3501 workspace.panel::<AgentPanel>(cx)
3502 {
3503 panel.update(cx, |panel, cx| {
3504 panel.new_agent_thread(
3505 AgentType::Custom {
3506 name: agent_id.0.clone(),
3507 },
3508 window,
3509 cx,
3510 );
3511 });
3512 }
3513 });
3514 }
3515 }
3516 });
3517
3518 menu = menu.item(entry);
3519 }
3520
3521 menu
3522 })
3523 .separator()
3524 .map(|mut menu| {
3525 let agent_server_store = agent_server_store.read(cx);
3526 let registry_store =
3527 project::AgentRegistryStore::try_global(cx);
3528 let registry_store_ref =
3529 registry_store.as_ref().map(|s| s.read(cx));
3530
3531 let previous_built_in_ids: &[ExternalAgentServerName] =
3532 &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()];
3533
3534 let promoted_items = previous_built_in_ids
3535 .iter()
3536 .filter(|id| {
3537 !agent_server_store.external_agents.contains_key(*id)
3538 })
3539 .filter_map(|name| {
3540 let display_name = registry_store_ref
3541 .as_ref()
3542 .and_then(|store| store.agent(name.0.as_ref()))
3543 .map(|a| a.name().clone())?;
3544 Some((name.clone(), display_name))
3545 })
3546 .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase())
3547 .collect::<Vec<_>>();
3548
3549 for (agent_id, display_name) in &promoted_items {
3550 let mut entry =
3551 ContextMenuEntry::new(display_name.clone());
3552
3553 let icon_path = registry_store_ref
3554 .as_ref()
3555 .and_then(|store| store.agent(agent_id.0.as_str()))
3556 .and_then(|a| a.icon_path().cloned());
3557
3558 if let Some(icon_path) = icon_path {
3559 entry = entry.custom_icon_svg(icon_path);
3560 } else {
3561 entry = entry.icon(IconName::Sparkle);
3562 }
3563
3564 entry = entry
3565 .icon_color(Color::Muted)
3566 .disabled(is_via_collab)
3567 .handler({
3568 let workspace = workspace.clone();
3569 let agent_id = agent_id.clone();
3570 move |window, cx| {
3571 let fs = <dyn fs::Fs>::global(cx);
3572 let agent_id_string =
3573 agent_id.to_string();
3574 settings::update_settings_file(
3575 fs,
3576 cx,
3577 move |settings, _| {
3578 let agent_servers = settings
3579 .agent_servers
3580 .get_or_insert_default();
3581 agent_servers.entry(agent_id_string).or_insert_with(|| {
3582 settings::CustomAgentServerSettings::Registry {
3583 default_mode: None,
3584 default_model: None,
3585 env: Default::default(),
3586 favorite_models: Vec::new(),
3587 default_config_options: Default::default(),
3588 favorite_config_option_values: Default::default(),
3589 }
3590 });
3591 },
3592 );
3593
3594 if let Some(workspace) = workspace.upgrade() {
3595 workspace.update(cx, |workspace, cx| {
3596 if let Some(panel) =
3597 workspace.panel::<AgentPanel>(cx)
3598 {
3599 panel.update(cx, |panel, cx| {
3600 panel.new_agent_thread(
3601 AgentType::Custom {
3602 name: agent_id.0.clone(),
3603 },
3604 window,
3605 cx,
3606 );
3607 });
3608 }
3609 });
3610 }
3611 }
3612 });
3613
3614 menu = menu.item(entry);
3615 }
3616
3617 menu
3618 })
3619 .item(
3620 ContextMenuEntry::new("Add More Agents")
3621 .icon(IconName::Plus)
3622 .icon_color(Color::Muted)
3623 .handler({
3624 move |window, cx| {
3625 window.dispatch_action(
3626 Box::new(zed_actions::AcpRegistry),
3627 cx,
3628 )
3629 }
3630 }),
3631 )
3632 }))
3633 })
3634 };
3635
3636 let is_thread_loading = self
3637 .active_connection_view()
3638 .map(|thread| thread.read(cx).is_loading())
3639 .unwrap_or(false);
3640
3641 let has_custom_icon = selected_agent_custom_icon.is_some();
3642 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
3643 let selected_agent_builtin_icon = self.selected_agent.icon();
3644 let selected_agent_label_for_tooltip = selected_agent_label.clone();
3645
3646 let selected_agent = div()
3647 .id("selected_agent_icon")
3648 .when_some(selected_agent_custom_icon, |this, icon_path| {
3649 this.px_1()
3650 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
3651 })
3652 .when(!has_custom_icon, |this| {
3653 this.when_some(self.selected_agent.icon(), |this, icon| {
3654 this.px_1().child(Icon::new(icon).color(Color::Muted))
3655 })
3656 })
3657 .tooltip(move |_, cx| {
3658 Tooltip::with_meta(
3659 selected_agent_label_for_tooltip.clone(),
3660 None,
3661 "Selected Agent",
3662 cx,
3663 )
3664 });
3665
3666 let selected_agent = if is_thread_loading {
3667 selected_agent
3668 .with_animation(
3669 "pulsating-icon",
3670 Animation::new(Duration::from_secs(1))
3671 .repeat()
3672 .with_easing(pulsating_between(0.2, 0.6)),
3673 |icon, delta| icon.opacity(delta),
3674 )
3675 .into_any_element()
3676 } else {
3677 selected_agent.into_any_element()
3678 };
3679
3680 let show_history_menu = self.history_kind_for_selected_agent(cx).is_some();
3681 let has_v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
3682 let is_empty_state = !self.active_thread_has_messages(cx);
3683
3684 let is_in_history_or_config = matches!(
3685 &self.active_view,
3686 ActiveView::History { .. } | ActiveView::Configuration
3687 );
3688
3689 let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config;
3690
3691 if use_v2_empty_toolbar {
3692 let (chevron_icon, icon_color, label_color) =
3693 if self.new_thread_menu_handle.is_deployed() {
3694 (IconName::ChevronUp, Color::Accent, Color::Accent)
3695 } else {
3696 (IconName::ChevronDown, Color::Muted, Color::Default)
3697 };
3698
3699 let agent_icon_element: AnyElement =
3700 if let Some(icon_path) = selected_agent_custom_icon_for_button {
3701 Icon::from_external_svg(icon_path)
3702 .size(IconSize::Small)
3703 .color(icon_color)
3704 .into_any_element()
3705 } else {
3706 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
3707 Icon::new(icon_name)
3708 .size(IconSize::Small)
3709 .color(icon_color)
3710 .into_any_element()
3711 };
3712
3713 let agent_selector_button = ButtonLike::new("agent-selector-trigger")
3714 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
3715 .child(
3716 h_flex()
3717 .gap_1()
3718 .child(agent_icon_element)
3719 .child(Label::new(selected_agent_label).color(label_color).ml_0p5())
3720 .child(
3721 Icon::new(chevron_icon)
3722 .color(icon_color)
3723 .size(IconSize::XSmall),
3724 ),
3725 );
3726
3727 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
3728 .trigger_with_tooltip(agent_selector_button, {
3729 move |_window, cx| {
3730 Tooltip::for_action_in(
3731 "New Thread\u{2026}",
3732 &ToggleNewThreadMenu,
3733 &focus_handle,
3734 cx,
3735 )
3736 }
3737 })
3738 .menu({
3739 let builder = new_thread_menu_builder.clone();
3740 move |window, cx| builder(window, cx)
3741 })
3742 .with_handle(self.new_thread_menu_handle.clone())
3743 .anchor(Corner::TopLeft)
3744 .offset(gpui::Point {
3745 x: px(1.0),
3746 y: px(1.0),
3747 });
3748
3749 h_flex()
3750 .id("agent-panel-toolbar")
3751 .h(Tab::container_height(cx))
3752 .max_w_full()
3753 .flex_none()
3754 .justify_between()
3755 .gap_2()
3756 .bg(cx.theme().colors().tab_bar_background)
3757 .border_b_1()
3758 .border_color(cx.theme().colors().border)
3759 .child(
3760 h_flex()
3761 .size_full()
3762 .gap(DynamicSpacing::Base04.rems(cx))
3763 .pl(DynamicSpacing::Base04.rems(cx))
3764 .child(agent_selector_menu)
3765 .child(self.render_start_thread_in_selector(cx)),
3766 )
3767 .child(
3768 h_flex()
3769 .flex_none()
3770 .gap(DynamicSpacing::Base02.rems(cx))
3771 .pl(DynamicSpacing::Base04.rems(cx))
3772 .pr(DynamicSpacing::Base06.rems(cx))
3773 .when(show_history_menu, |this| {
3774 this.child(self.render_recent_entries_menu(
3775 IconName::MenuAltTemp,
3776 Corner::TopRight,
3777 cx,
3778 ))
3779 })
3780 .child(self.render_panel_options_menu(window, cx)),
3781 )
3782 .into_any_element()
3783 } else {
3784 let new_thread_menu = PopoverMenu::new("new_thread_menu")
3785 .trigger_with_tooltip(
3786 IconButton::new("new_thread_menu_btn", IconName::Plus)
3787 .icon_size(IconSize::Small),
3788 {
3789 move |_window, cx| {
3790 Tooltip::for_action_in(
3791 "New Thread\u{2026}",
3792 &ToggleNewThreadMenu,
3793 &focus_handle,
3794 cx,
3795 )
3796 }
3797 },
3798 )
3799 .anchor(Corner::TopRight)
3800 .with_handle(self.new_thread_menu_handle.clone())
3801 .menu(move |window, cx| new_thread_menu_builder(window, cx));
3802
3803 h_flex()
3804 .id("agent-panel-toolbar")
3805 .h(Tab::container_height(cx))
3806 .max_w_full()
3807 .flex_none()
3808 .justify_between()
3809 .gap_2()
3810 .bg(cx.theme().colors().tab_bar_background)
3811 .border_b_1()
3812 .border_color(cx.theme().colors().border)
3813 .child(
3814 h_flex()
3815 .size_full()
3816 .gap(DynamicSpacing::Base04.rems(cx))
3817 .pl(DynamicSpacing::Base04.rems(cx))
3818 .child(match &self.active_view {
3819 ActiveView::History { .. } | ActiveView::Configuration => {
3820 self.render_toolbar_back_button(cx).into_any_element()
3821 }
3822 _ => selected_agent.into_any_element(),
3823 })
3824 .child(self.render_title_view(window, cx)),
3825 )
3826 .child(
3827 h_flex()
3828 .flex_none()
3829 .gap(DynamicSpacing::Base02.rems(cx))
3830 .pl(DynamicSpacing::Base04.rems(cx))
3831 .pr(DynamicSpacing::Base06.rems(cx))
3832 .child(new_thread_menu)
3833 .when(show_history_menu, |this| {
3834 this.child(self.render_recent_entries_menu(
3835 IconName::MenuAltTemp,
3836 Corner::TopRight,
3837 cx,
3838 ))
3839 })
3840 .child(self.render_panel_options_menu(window, cx)),
3841 )
3842 .into_any_element()
3843 }
3844 }
3845
3846 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3847 let status = self.worktree_creation_status.as_ref()?;
3848 match status {
3849 WorktreeCreationStatus::Creating => Some(
3850 h_flex()
3851 .w_full()
3852 .px(DynamicSpacing::Base06.rems(cx))
3853 .py(DynamicSpacing::Base02.rems(cx))
3854 .gap_2()
3855 .bg(cx.theme().colors().surface_background)
3856 .border_b_1()
3857 .border_color(cx.theme().colors().border)
3858 .child(SpinnerLabel::new().size(LabelSize::Small))
3859 .child(
3860 Label::new("Creating worktree…")
3861 .color(Color::Muted)
3862 .size(LabelSize::Small),
3863 )
3864 .into_any_element(),
3865 ),
3866 WorktreeCreationStatus::Error(message) => Some(
3867 h_flex()
3868 .w_full()
3869 .px(DynamicSpacing::Base06.rems(cx))
3870 .py(DynamicSpacing::Base02.rems(cx))
3871 .gap_2()
3872 .bg(cx.theme().colors().surface_background)
3873 .border_b_1()
3874 .border_color(cx.theme().colors().border)
3875 .child(
3876 Icon::new(IconName::Warning)
3877 .size(IconSize::Small)
3878 .color(Color::Warning),
3879 )
3880 .child(
3881 Label::new(message.clone())
3882 .color(Color::Warning)
3883 .size(LabelSize::Small)
3884 .truncate(),
3885 )
3886 .into_any_element(),
3887 ),
3888 }
3889 }
3890
3891 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
3892 if TrialEndUpsell::dismissed() {
3893 return false;
3894 }
3895
3896 match &self.active_view {
3897 ActiveView::TextThread { .. } => {
3898 if LanguageModelRegistry::global(cx)
3899 .read(cx)
3900 .default_model()
3901 .is_some_and(|model| {
3902 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3903 })
3904 {
3905 return false;
3906 }
3907 }
3908 ActiveView::Uninitialized
3909 | ActiveView::AgentThread { .. }
3910 | ActiveView::History { .. }
3911 | ActiveView::Configuration => return false,
3912 }
3913
3914 let plan = self.user_store.read(cx).plan();
3915 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
3916
3917 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
3918 }
3919
3920 fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
3921 if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
3922 return false;
3923 }
3924
3925 let user_store = self.user_store.read(cx);
3926
3927 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
3928 && user_store
3929 .subscription_period()
3930 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
3931 .is_some_and(|date| date < chrono::Utc::now())
3932 {
3933 OnboardingUpsell::set_dismissed(true, cx);
3934 self.on_boarding_upsell_dismissed
3935 .store(true, Ordering::Release);
3936 return false;
3937 }
3938
3939 match &self.active_view {
3940 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3941 false
3942 }
3943 ActiveView::AgentThread { server_view, .. }
3944 if server_view.read(cx).as_native_thread(cx).is_none() =>
3945 {
3946 false
3947 }
3948 _ => {
3949 let history_is_empty = self.acp_history.read(cx).is_empty();
3950
3951 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
3952 .visible_providers()
3953 .iter()
3954 .any(|provider| {
3955 provider.is_authenticated(cx)
3956 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3957 });
3958
3959 history_is_empty || !has_configured_non_zed_providers
3960 }
3961 }
3962 }
3963
3964 fn render_onboarding(
3965 &self,
3966 _window: &mut Window,
3967 cx: &mut Context<Self>,
3968 ) -> Option<impl IntoElement> {
3969 if !self.should_render_onboarding(cx) {
3970 return None;
3971 }
3972
3973 let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. });
3974
3975 Some(
3976 div()
3977 .when(text_thread_view, |this| {
3978 this.bg(cx.theme().colors().editor_background)
3979 })
3980 .child(self.onboarding.clone()),
3981 )
3982 }
3983
3984 fn render_trial_end_upsell(
3985 &self,
3986 _window: &mut Window,
3987 cx: &mut Context<Self>,
3988 ) -> Option<impl IntoElement> {
3989 if !self.should_render_trial_end_upsell(cx) {
3990 return None;
3991 }
3992
3993 Some(
3994 v_flex()
3995 .absolute()
3996 .inset_0()
3997 .size_full()
3998 .bg(cx.theme().colors().panel_background)
3999 .opacity(0.85)
4000 .block_mouse_except_scroll()
4001 .child(EndTrialUpsell::new(Arc::new({
4002 let this = cx.entity();
4003 move |_, cx| {
4004 this.update(cx, |_this, cx| {
4005 TrialEndUpsell::set_dismissed(true, cx);
4006 cx.notify();
4007 });
4008 }
4009 }))),
4010 )
4011 }
4012
4013 fn emit_configuration_error_telemetry_if_needed(
4014 &mut self,
4015 configuration_error: Option<&ConfigurationError>,
4016 ) {
4017 let error_kind = configuration_error.map(|err| match err {
4018 ConfigurationError::NoProvider => "no_provider",
4019 ConfigurationError::ModelNotFound => "model_not_found",
4020 ConfigurationError::ProviderNotAuthenticated(_) => "provider_not_authenticated",
4021 });
4022
4023 let error_kind_string = error_kind.map(String::from);
4024
4025 if self.last_configuration_error_telemetry == error_kind_string {
4026 return;
4027 }
4028
4029 self.last_configuration_error_telemetry = error_kind_string;
4030
4031 if let Some(kind) = error_kind {
4032 let message = configuration_error
4033 .map(|err| err.to_string())
4034 .unwrap_or_default();
4035
4036 telemetry::event!("Agent Panel Error Shown", kind = kind, message = message,);
4037 }
4038 }
4039
4040 fn render_configuration_error(
4041 &self,
4042 border_bottom: bool,
4043 configuration_error: &ConfigurationError,
4044 focus_handle: &FocusHandle,
4045 cx: &mut App,
4046 ) -> impl IntoElement {
4047 let zed_provider_configured = AgentSettings::get_global(cx)
4048 .default_model
4049 .as_ref()
4050 .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev");
4051
4052 let callout = if zed_provider_configured {
4053 Callout::new()
4054 .icon(IconName::Warning)
4055 .severity(Severity::Warning)
4056 .when(border_bottom, |this| {
4057 this.border_position(ui::BorderPosition::Bottom)
4058 })
4059 .title("Sign in to continue using Zed as your LLM provider.")
4060 .actions_slot(
4061 Button::new("sign_in", "Sign In")
4062 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
4063 .label_size(LabelSize::Small)
4064 .on_click({
4065 let workspace = self.workspace.clone();
4066 move |_, _, cx| {
4067 let Ok(client) =
4068 workspace.update(cx, |workspace, _| workspace.client().clone())
4069 else {
4070 return;
4071 };
4072
4073 cx.spawn(async move |cx| {
4074 client.sign_in_with_optional_connect(true, cx).await
4075 })
4076 .detach_and_log_err(cx);
4077 }
4078 }),
4079 )
4080 } else {
4081 Callout::new()
4082 .icon(IconName::Warning)
4083 .severity(Severity::Warning)
4084 .when(border_bottom, |this| {
4085 this.border_position(ui::BorderPosition::Bottom)
4086 })
4087 .title(configuration_error.to_string())
4088 .actions_slot(
4089 Button::new("settings", "Configure")
4090 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
4091 .label_size(LabelSize::Small)
4092 .key_binding(
4093 KeyBinding::for_action_in(&OpenSettings, focus_handle, cx)
4094 .map(|kb| kb.size(rems_from_px(12.))),
4095 )
4096 .on_click(|_event, window, cx| {
4097 window.dispatch_action(OpenSettings.boxed_clone(), cx)
4098 }),
4099 )
4100 };
4101
4102 match configuration_error {
4103 ConfigurationError::ModelNotFound
4104 | ConfigurationError::ProviderNotAuthenticated(_)
4105 | ConfigurationError::NoProvider => callout.into_any_element(),
4106 }
4107 }
4108
4109 fn render_text_thread(
4110 &self,
4111 text_thread_editor: &Entity<TextThreadEditor>,
4112 buffer_search_bar: &Entity<BufferSearchBar>,
4113 window: &mut Window,
4114 cx: &mut Context<Self>,
4115 ) -> Div {
4116 let mut registrar = buffer_search::DivRegistrar::new(
4117 |this, _, _cx| match &this.active_view {
4118 ActiveView::TextThread {
4119 buffer_search_bar, ..
4120 } => Some(buffer_search_bar.clone()),
4121 _ => None,
4122 },
4123 cx,
4124 );
4125 BufferSearchBar::register(&mut registrar);
4126 registrar
4127 .into_div()
4128 .size_full()
4129 .relative()
4130 .map(|parent| {
4131 buffer_search_bar.update(cx, |buffer_search_bar, cx| {
4132 if buffer_search_bar.is_dismissed() {
4133 return parent;
4134 }
4135 parent.child(
4136 div()
4137 .p(DynamicSpacing::Base08.rems(cx))
4138 .border_b_1()
4139 .border_color(cx.theme().colors().border_variant)
4140 .bg(cx.theme().colors().editor_background)
4141 .child(buffer_search_bar.render(window, cx)),
4142 )
4143 })
4144 })
4145 .child(text_thread_editor.clone())
4146 .child(self.render_drag_target(cx))
4147 }
4148
4149 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4150 let is_local = self.project.read(cx).is_local();
4151 div()
4152 .invisible()
4153 .absolute()
4154 .top_0()
4155 .right_0()
4156 .bottom_0()
4157 .left_0()
4158 .bg(cx.theme().colors().drop_target_background)
4159 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4160 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4161 .when(is_local, |this| {
4162 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4163 })
4164 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4165 let item = tab.pane.read(cx).item_for_index(tab.ix);
4166 let project_paths = item
4167 .and_then(|item| item.project_path(cx))
4168 .into_iter()
4169 .collect::<Vec<_>>();
4170 this.handle_drop(project_paths, vec![], window, cx);
4171 }))
4172 .on_drop(
4173 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4174 let project_paths = selection
4175 .items()
4176 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4177 .collect::<Vec<_>>();
4178 this.handle_drop(project_paths, vec![], window, cx);
4179 }),
4180 )
4181 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4182 let tasks = paths
4183 .paths()
4184 .iter()
4185 .map(|path| {
4186 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4187 })
4188 .collect::<Vec<_>>();
4189 cx.spawn_in(window, async move |this, cx| {
4190 let mut paths = vec![];
4191 let mut added_worktrees = vec![];
4192 let opened_paths = futures::future::join_all(tasks).await;
4193 for entry in opened_paths {
4194 if let Some((worktree, project_path)) = entry.log_err() {
4195 added_worktrees.push(worktree);
4196 paths.push(project_path);
4197 }
4198 }
4199 this.update_in(cx, |this, window, cx| {
4200 this.handle_drop(paths, added_worktrees, window, cx);
4201 })
4202 .ok();
4203 })
4204 .detach();
4205 }))
4206 }
4207
4208 fn handle_drop(
4209 &mut self,
4210 paths: Vec<ProjectPath>,
4211 added_worktrees: Vec<Entity<Worktree>>,
4212 window: &mut Window,
4213 cx: &mut Context<Self>,
4214 ) {
4215 match &self.active_view {
4216 ActiveView::AgentThread { server_view } => {
4217 server_view.update(cx, |thread_view, cx| {
4218 thread_view.insert_dragged_files(paths, added_worktrees, window, cx);
4219 });
4220 }
4221 ActiveView::TextThread {
4222 text_thread_editor, ..
4223 } => {
4224 text_thread_editor.update(cx, |text_thread_editor, cx| {
4225 TextThreadEditor::insert_dragged_files(
4226 text_thread_editor,
4227 paths,
4228 added_worktrees,
4229 window,
4230 cx,
4231 );
4232 });
4233 }
4234 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4235 }
4236 }
4237
4238 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4239 if !self.show_trust_workspace_message {
4240 return None;
4241 }
4242
4243 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4244
4245 Some(
4246 Callout::new()
4247 .icon(IconName::Warning)
4248 .severity(Severity::Warning)
4249 .border_position(ui::BorderPosition::Bottom)
4250 .title("You're in Restricted Mode")
4251 .description(description)
4252 .actions_slot(
4253 Button::new("open-trust-modal", "Configure Project Trust")
4254 .label_size(LabelSize::Small)
4255 .style(ButtonStyle::Outlined)
4256 .on_click({
4257 cx.listener(move |this, _, window, cx| {
4258 this.workspace
4259 .update(cx, |workspace, cx| {
4260 workspace
4261 .show_worktree_trust_security_modal(true, window, cx)
4262 })
4263 .log_err();
4264 })
4265 }),
4266 ),
4267 )
4268 }
4269
4270 fn key_context(&self) -> KeyContext {
4271 let mut key_context = KeyContext::new_with_defaults();
4272 key_context.add("AgentPanel");
4273 match &self.active_view {
4274 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4275 ActiveView::TextThread { .. } => key_context.add("text_thread"),
4276 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4277 }
4278 key_context
4279 }
4280}
4281
4282impl Render for AgentPanel {
4283 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4284 // WARNING: Changes to this element hierarchy can have
4285 // non-obvious implications to the layout of children.
4286 //
4287 // If you need to change it, please confirm:
4288 // - The message editor expands (cmd-option-esc) correctly
4289 // - When expanded, the buttons at the bottom of the panel are displayed correctly
4290 // - Font size works as expected and can be changed with cmd-+/cmd-
4291 // - Scrolling in all views works as expected
4292 // - Files can be dropped into the panel
4293 let content = v_flex()
4294 .relative()
4295 .size_full()
4296 .justify_between()
4297 .key_context(self.key_context())
4298 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4299 this.new_thread(action, window, cx);
4300 }))
4301 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4302 this.open_history(window, cx);
4303 }))
4304 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4305 this.open_configuration(window, cx);
4306 }))
4307 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4308 .on_action(cx.listener(Self::deploy_rules_library))
4309 .on_action(cx.listener(Self::go_back))
4310 .on_action(cx.listener(Self::toggle_navigation_menu))
4311 .on_action(cx.listener(Self::toggle_options_menu))
4312 .on_action(cx.listener(Self::toggle_start_thread_in_selector))
4313 .on_action(cx.listener(Self::increase_font_size))
4314 .on_action(cx.listener(Self::decrease_font_size))
4315 .on_action(cx.listener(Self::reset_font_size))
4316 .on_action(cx.listener(Self::toggle_zoom))
4317 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4318 if let Some(thread_view) = this.active_connection_view() {
4319 thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx))
4320 }
4321 }))
4322 .child(self.render_toolbar(window, cx))
4323 .children(self.render_worktree_creation_status(cx))
4324 .children(self.render_workspace_trust_message(cx))
4325 .children(self.render_onboarding(window, cx))
4326 .map(|parent| {
4327 // Emit configuration error telemetry before entering the match to avoid borrow conflicts
4328 if matches!(&self.active_view, ActiveView::TextThread { .. }) {
4329 let model_registry = LanguageModelRegistry::read_global(cx);
4330 let configuration_error =
4331 model_registry.configuration_error(model_registry.default_model(), cx);
4332 self.emit_configuration_error_telemetry_if_needed(configuration_error.as_ref());
4333 }
4334
4335 match &self.active_view {
4336 ActiveView::Uninitialized => parent,
4337 ActiveView::AgentThread { server_view, .. } => parent
4338 .child(server_view.clone())
4339 .child(self.render_drag_target(cx)),
4340 ActiveView::History { kind } => match kind {
4341 HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
4342 HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
4343 },
4344 ActiveView::TextThread {
4345 text_thread_editor,
4346 buffer_search_bar,
4347 ..
4348 } => {
4349 let model_registry = LanguageModelRegistry::read_global(cx);
4350 let configuration_error =
4351 model_registry.configuration_error(model_registry.default_model(), cx);
4352
4353 parent
4354 .map(|this| {
4355 if !self.should_render_onboarding(cx)
4356 && let Some(err) = configuration_error.as_ref()
4357 {
4358 this.child(self.render_configuration_error(
4359 true,
4360 err,
4361 &self.focus_handle(cx),
4362 cx,
4363 ))
4364 } else {
4365 this
4366 }
4367 })
4368 .child(self.render_text_thread(
4369 text_thread_editor,
4370 buffer_search_bar,
4371 window,
4372 cx,
4373 ))
4374 }
4375 ActiveView::Configuration => parent.children(self.configuration.clone()),
4376 }
4377 })
4378 .children(self.render_trial_end_upsell(window, cx));
4379
4380 match self.active_view.which_font_size_used() {
4381 WhichFontSize::AgentFont => {
4382 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4383 .size_full()
4384 .child(content)
4385 .into_any()
4386 }
4387 _ => content.into_any(),
4388 }
4389 }
4390}
4391
4392struct PromptLibraryInlineAssist {
4393 workspace: WeakEntity<Workspace>,
4394}
4395
4396impl PromptLibraryInlineAssist {
4397 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4398 Self { workspace }
4399 }
4400}
4401
4402impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4403 fn assist(
4404 &self,
4405 prompt_editor: &Entity<Editor>,
4406 initial_prompt: Option<String>,
4407 window: &mut Window,
4408 cx: &mut Context<RulesLibrary>,
4409 ) {
4410 InlineAssistant::update_global(cx, |assistant, cx| {
4411 let Some(workspace) = self.workspace.upgrade() else {
4412 return;
4413 };
4414 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4415 return;
4416 };
4417 let project = workspace.read(cx).project().downgrade();
4418 let panel = panel.read(cx);
4419 let thread_store = panel.thread_store().clone();
4420 let history = panel.history().downgrade();
4421 assistant.assist(
4422 prompt_editor,
4423 self.workspace.clone(),
4424 project,
4425 thread_store,
4426 None,
4427 history,
4428 initial_prompt,
4429 window,
4430 cx,
4431 );
4432 })
4433 }
4434
4435 fn focus_agent_panel(
4436 &self,
4437 workspace: &mut Workspace,
4438 window: &mut Window,
4439 cx: &mut Context<Workspace>,
4440 ) -> bool {
4441 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4442 }
4443}
4444
4445pub struct ConcreteAssistantPanelDelegate;
4446
4447impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
4448 fn active_text_thread_editor(
4449 &self,
4450 workspace: &mut Workspace,
4451 _window: &mut Window,
4452 cx: &mut Context<Workspace>,
4453 ) -> Option<Entity<TextThreadEditor>> {
4454 let panel = workspace.panel::<AgentPanel>(cx)?;
4455 panel.read(cx).active_text_thread_editor()
4456 }
4457
4458 fn open_local_text_thread(
4459 &self,
4460 workspace: &mut Workspace,
4461 path: Arc<Path>,
4462 window: &mut Window,
4463 cx: &mut Context<Workspace>,
4464 ) -> Task<Result<()>> {
4465 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4466 return Task::ready(Err(anyhow!("Agent panel not found")));
4467 };
4468
4469 panel.update(cx, |panel, cx| {
4470 panel.open_saved_text_thread(path, window, cx)
4471 })
4472 }
4473
4474 fn open_remote_text_thread(
4475 &self,
4476 _workspace: &mut Workspace,
4477 _text_thread_id: assistant_text_thread::TextThreadId,
4478 _window: &mut Window,
4479 _cx: &mut Context<Workspace>,
4480 ) -> Task<Result<Entity<TextThreadEditor>>> {
4481 Task::ready(Err(anyhow!("opening remote context not implemented")))
4482 }
4483
4484 fn quote_selection(
4485 &self,
4486 workspace: &mut Workspace,
4487 selection_ranges: Vec<Range<Anchor>>,
4488 buffer: Entity<MultiBuffer>,
4489 window: &mut Window,
4490 cx: &mut Context<Workspace>,
4491 ) {
4492 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4493 return;
4494 };
4495
4496 if !panel.focus_handle(cx).contains_focused(window, cx) {
4497 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
4498 }
4499
4500 panel.update(cx, |_, cx| {
4501 // Wait to create a new context until the workspace is no longer
4502 // being updated.
4503 cx.defer_in(window, move |panel, window, cx| {
4504 if let Some(thread_view) = panel.active_connection_view() {
4505 thread_view.update(cx, |thread_view, cx| {
4506 thread_view.insert_selections(window, cx);
4507 });
4508 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
4509 let snapshot = buffer.read(cx).snapshot(cx);
4510 let selection_ranges = selection_ranges
4511 .into_iter()
4512 .map(|range| range.to_point(&snapshot))
4513 .collect::<Vec<_>>();
4514
4515 text_thread_editor.update(cx, |text_thread_editor, cx| {
4516 text_thread_editor.quote_ranges(selection_ranges, snapshot, window, cx)
4517 });
4518 }
4519 });
4520 });
4521 }
4522
4523 fn quote_terminal_text(
4524 &self,
4525 workspace: &mut Workspace,
4526 text: String,
4527 window: &mut Window,
4528 cx: &mut Context<Workspace>,
4529 ) {
4530 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
4531 return;
4532 };
4533
4534 if !panel.focus_handle(cx).contains_focused(window, cx) {
4535 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
4536 }
4537
4538 panel.update(cx, |_, cx| {
4539 // Wait to create a new context until the workspace is no longer
4540 // being updated.
4541 cx.defer_in(window, move |panel, window, cx| {
4542 if let Some(thread_view) = panel.active_connection_view() {
4543 thread_view.update(cx, |thread_view, cx| {
4544 thread_view.insert_terminal_text(text, window, cx);
4545 });
4546 } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
4547 text_thread_editor.update(cx, |text_thread_editor, cx| {
4548 text_thread_editor.quote_terminal_text(text, window, cx)
4549 });
4550 }
4551 });
4552 });
4553 }
4554}
4555
4556struct OnboardingUpsell;
4557
4558impl Dismissable for OnboardingUpsell {
4559 const KEY: &'static str = "dismissed-trial-upsell";
4560}
4561
4562struct TrialEndUpsell;
4563
4564impl Dismissable for TrialEndUpsell {
4565 const KEY: &'static str = "dismissed-trial-end-upsell";
4566}
4567
4568/// Test-only helper methods
4569#[cfg(any(test, feature = "test-support"))]
4570impl AgentPanel {
4571 pub fn test_new(
4572 workspace: &Workspace,
4573 text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
4574 window: &mut Window,
4575 cx: &mut Context<Self>,
4576 ) -> Self {
4577 Self::new(workspace, text_thread_store, None, window, cx)
4578 }
4579
4580 /// Opens an external thread using an arbitrary AgentServer.
4581 ///
4582 /// This is a test-only helper that allows visual tests and integration tests
4583 /// to inject a stub server without modifying production code paths.
4584 /// Not compiled into production builds.
4585 pub fn open_external_thread_with_server(
4586 &mut self,
4587 server: Rc<dyn AgentServer>,
4588 window: &mut Window,
4589 cx: &mut Context<Self>,
4590 ) {
4591 let workspace = self.workspace.clone();
4592 let project = self.project.clone();
4593
4594 let ext_agent = ExternalAgent::Custom {
4595 name: server.name(),
4596 };
4597
4598 self.create_external_thread(
4599 server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
4600 );
4601 }
4602
4603 /// Returns the currently active thread view, if any.
4604 ///
4605 /// This is a test-only accessor that exposes the private `active_thread_view()`
4606 /// method for test assertions. Not compiled into production builds.
4607 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConnectionView>> {
4608 self.active_connection_view()
4609 }
4610
4611 /// Sets the start_thread_in value directly, bypassing validation.
4612 ///
4613 /// This is a test-only helper for visual tests that need to show specific
4614 /// start_thread_in states without requiring a real git repository.
4615 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4616 self.start_thread_in = target;
4617 cx.notify();
4618 }
4619
4620 /// Returns the current worktree creation status.
4621 ///
4622 /// This is a test-only helper for visual tests.
4623 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4624 self.worktree_creation_status.as_ref()
4625 }
4626
4627 /// Sets the worktree creation status directly.
4628 ///
4629 /// This is a test-only helper for visual tests that need to show the
4630 /// "Creating worktree…" spinner or error banners.
4631 pub fn set_worktree_creation_status_for_tests(
4632 &mut self,
4633 status: Option<WorktreeCreationStatus>,
4634 cx: &mut Context<Self>,
4635 ) {
4636 self.worktree_creation_status = status;
4637 cx.notify();
4638 }
4639
4640 /// Opens the history view.
4641 ///
4642 /// This is a test-only helper that exposes the private `open_history()`
4643 /// method for visual tests.
4644 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4645 self.open_history(window, cx);
4646 }
4647
4648 /// Opens the start_thread_in selector popover menu.
4649 ///
4650 /// This is a test-only helper for visual tests.
4651 pub fn open_start_thread_in_menu_for_tests(
4652 &mut self,
4653 window: &mut Window,
4654 cx: &mut Context<Self>,
4655 ) {
4656 self.start_thread_in_menu_handle.show(window, cx);
4657 }
4658
4659 /// Dismisses the start_thread_in dropdown menu.
4660 ///
4661 /// This is a test-only helper for visual tests.
4662 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4663 self.start_thread_in_menu_handle.hide(cx);
4664 }
4665}
4666
4667#[cfg(test)]
4668mod tests {
4669 use super::*;
4670 use crate::connection_view::tests::{StubAgentServer, init_test};
4671 use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
4672 use acp_thread::{StubAgentConnection, ThreadStatus};
4673 use assistant_text_thread::TextThreadStore;
4674 use feature_flags::FeatureFlagAppExt;
4675 use fs::FakeFs;
4676 use gpui::{TestAppContext, VisualTestContext};
4677 use project::Project;
4678 use serde_json::json;
4679 use workspace::MultiWorkspace;
4680
4681 #[gpui::test]
4682 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4683 init_test(cx);
4684 cx.update(|cx| {
4685 cx.update_flags(true, vec!["agent-v2".to_string()]);
4686 agent::ThreadStore::init_global(cx);
4687 language_model::LanguageModelRegistry::test(cx);
4688 });
4689
4690 // --- Create a MultiWorkspace window with two workspaces ---
4691 let fs = FakeFs::new(cx.executor());
4692 let project_a = Project::test(fs.clone(), [], cx).await;
4693 let project_b = Project::test(fs, [], cx).await;
4694
4695 let multi_workspace =
4696 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4697
4698 let workspace_a = multi_workspace
4699 .read_with(cx, |multi_workspace, _cx| {
4700 multi_workspace.workspace().clone()
4701 })
4702 .unwrap();
4703
4704 let workspace_b = multi_workspace
4705 .update(cx, |multi_workspace, window, cx| {
4706 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4707 })
4708 .unwrap();
4709
4710 workspace_a.update(cx, |workspace, _cx| {
4711 workspace.set_random_database_id();
4712 });
4713 workspace_b.update(cx, |workspace, _cx| {
4714 workspace.set_random_database_id();
4715 });
4716
4717 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4718
4719 // --- Set up workspace A: width=300, with an active thread ---
4720 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4721 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx));
4722 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
4723 });
4724
4725 panel_a.update(cx, |panel, _cx| {
4726 panel.width = Some(px(300.0));
4727 });
4728
4729 panel_a.update_in(cx, |panel, window, cx| {
4730 panel.open_external_thread_with_server(
4731 Rc::new(StubAgentServer::default_response()),
4732 window,
4733 cx,
4734 );
4735 });
4736
4737 cx.run_until_parked();
4738
4739 panel_a.read_with(cx, |panel, cx| {
4740 assert!(
4741 panel.active_agent_thread(cx).is_some(),
4742 "workspace A should have an active thread after connection"
4743 );
4744 });
4745
4746 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4747
4748 // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
4749 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4750 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx));
4751 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
4752 });
4753
4754 panel_b.update(cx, |panel, _cx| {
4755 panel.width = Some(px(400.0));
4756 panel.selected_agent = AgentType::Custom {
4757 name: "claude-acp".into(),
4758 };
4759 });
4760
4761 // --- Serialize both panels ---
4762 panel_a.update(cx, |panel, cx| panel.serialize(cx));
4763 panel_b.update(cx, |panel, cx| panel.serialize(cx));
4764 cx.run_until_parked();
4765
4766 // --- Load fresh panels for each workspace and verify independent state ---
4767 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
4768
4769 let async_cx = cx.update(|window, cx| window.to_async(cx));
4770 let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx)
4771 .await
4772 .expect("panel A load should succeed");
4773 cx.run_until_parked();
4774
4775 let async_cx = cx.update(|window, cx| window.to_async(cx));
4776 let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx)
4777 .await
4778 .expect("panel B load should succeed");
4779 cx.run_until_parked();
4780
4781 // Workspace A should restore its thread, width, and agent type
4782 loaded_a.read_with(cx, |panel, _cx| {
4783 assert_eq!(
4784 panel.width,
4785 Some(px(300.0)),
4786 "workspace A width should be restored"
4787 );
4788 assert_eq!(
4789 panel.selected_agent, agent_type_a,
4790 "workspace A agent type should be restored"
4791 );
4792 assert!(
4793 panel.active_connection_view().is_some(),
4794 "workspace A should have its active thread restored"
4795 );
4796 });
4797
4798 // Workspace B should restore its own width and agent type, with no thread
4799 loaded_b.read_with(cx, |panel, _cx| {
4800 assert_eq!(
4801 panel.width,
4802 Some(px(400.0)),
4803 "workspace B width should be restored"
4804 );
4805 assert_eq!(
4806 panel.selected_agent,
4807 AgentType::Custom {
4808 name: "claude-acp".into()
4809 },
4810 "workspace B agent type should be restored"
4811 );
4812 assert!(
4813 panel.active_connection_view().is_none(),
4814 "workspace B should have no active thread"
4815 );
4816 });
4817 }
4818
4819 // Simple regression test
4820 #[gpui::test]
4821 async fn test_new_text_thread_action_handler(cx: &mut TestAppContext) {
4822 init_test(cx);
4823
4824 let fs = FakeFs::new(cx.executor());
4825
4826 cx.update(|cx| {
4827 cx.update_flags(true, vec!["agent-v2".to_string()]);
4828 agent::ThreadStore::init_global(cx);
4829 language_model::LanguageModelRegistry::test(cx);
4830 let slash_command_registry =
4831 assistant_slash_command::SlashCommandRegistry::default_global(cx);
4832 slash_command_registry
4833 .register_command(assistant_slash_commands::DefaultSlashCommand, false);
4834 <dyn fs::Fs>::set_global(fs.clone(), cx);
4835 });
4836
4837 let project = Project::test(fs.clone(), [], cx).await;
4838
4839 let multi_workspace =
4840 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4841
4842 let workspace_a = multi_workspace
4843 .read_with(cx, |multi_workspace, _cx| {
4844 multi_workspace.workspace().clone()
4845 })
4846 .unwrap();
4847
4848 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4849
4850 workspace_a.update_in(cx, |workspace, window, cx| {
4851 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4852 let panel =
4853 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
4854 workspace.add_panel(panel, window, cx);
4855 });
4856
4857 cx.run_until_parked();
4858
4859 workspace_a.update_in(cx, |_, window, cx| {
4860 window.dispatch_action(NewTextThread.boxed_clone(), cx);
4861 });
4862
4863 cx.run_until_parked();
4864 }
4865
4866 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
4867 init_test(cx);
4868 cx.update(|cx| {
4869 cx.update_flags(true, vec!["agent-v2".to_string()]);
4870 agent::ThreadStore::init_global(cx);
4871 language_model::LanguageModelRegistry::test(cx);
4872 });
4873
4874 let fs = FakeFs::new(cx.executor());
4875 let project = Project::test(fs.clone(), [], cx).await;
4876
4877 let multi_workspace =
4878 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4879
4880 let workspace = multi_workspace
4881 .read_with(cx, |mw, _cx| mw.workspace().clone())
4882 .unwrap();
4883
4884 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
4885
4886 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
4887 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4888 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
4889 });
4890
4891 (panel, cx)
4892 }
4893
4894 #[gpui::test]
4895 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
4896 let (panel, mut cx) = setup_panel(cx).await;
4897
4898 let connection_a = StubAgentConnection::new();
4899 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
4900 send_message(&panel, &mut cx);
4901
4902 let session_id_a = active_session_id(&panel, &cx);
4903
4904 // Send a chunk to keep thread A generating (don't end the turn).
4905 cx.update(|_, cx| {
4906 connection_a.send_update(
4907 session_id_a.clone(),
4908 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4909 cx,
4910 );
4911 });
4912 cx.run_until_parked();
4913
4914 // Verify thread A is generating.
4915 panel.read_with(&cx, |panel, cx| {
4916 let thread = panel.active_agent_thread(cx).unwrap();
4917 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
4918 assert!(panel.background_threads.is_empty());
4919 });
4920
4921 // Open a new thread B — thread A should be retained in background.
4922 let connection_b = StubAgentConnection::new();
4923 open_thread_with_connection(&panel, connection_b, &mut cx);
4924
4925 panel.read_with(&cx, |panel, _cx| {
4926 assert_eq!(
4927 panel.background_threads.len(),
4928 1,
4929 "Running thread A should be retained in background_views"
4930 );
4931 assert!(
4932 panel.background_threads.contains_key(&session_id_a),
4933 "Background view should be keyed by thread A's session ID"
4934 );
4935 });
4936 }
4937
4938 #[gpui::test]
4939 async fn test_idle_thread_dropped_when_navigating_away(cx: &mut TestAppContext) {
4940 let (panel, mut cx) = setup_panel(cx).await;
4941
4942 let connection_a = StubAgentConnection::new();
4943 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4944 acp::ContentChunk::new("Response".into()),
4945 )]);
4946 open_thread_with_connection(&panel, connection_a, &mut cx);
4947 send_message(&panel, &mut cx);
4948
4949 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
4950 panel.active_connection_view().unwrap().downgrade()
4951 });
4952
4953 // Thread A should be idle (auto-completed via set_next_prompt_updates).
4954 panel.read_with(&cx, |panel, cx| {
4955 let thread = panel.active_agent_thread(cx).unwrap();
4956 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
4957 });
4958
4959 // Open a new thread B — thread A should NOT be retained.
4960 let connection_b = StubAgentConnection::new();
4961 open_thread_with_connection(&panel, connection_b, &mut cx);
4962
4963 panel.read_with(&cx, |panel, _cx| {
4964 assert!(
4965 panel.background_threads.is_empty(),
4966 "Idle thread A should not be retained in background_views"
4967 );
4968 });
4969
4970 // Verify the old ConnectionView entity was dropped (no strong references remain).
4971 assert!(
4972 weak_view_a.upgrade().is_none(),
4973 "Idle ConnectionView should have been dropped"
4974 );
4975 }
4976
4977 #[gpui::test]
4978 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
4979 let (panel, mut cx) = setup_panel(cx).await;
4980
4981 let connection_a = StubAgentConnection::new();
4982 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
4983 send_message(&panel, &mut cx);
4984
4985 let session_id_a = active_session_id(&panel, &cx);
4986
4987 // Keep thread A generating.
4988 cx.update(|_, cx| {
4989 connection_a.send_update(
4990 session_id_a.clone(),
4991 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4992 cx,
4993 );
4994 });
4995 cx.run_until_parked();
4996
4997 // Open thread B — thread A goes to background.
4998 let connection_b = StubAgentConnection::new();
4999 open_thread_with_connection(&panel, connection_b, &mut cx);
5000
5001 let session_id_b = active_session_id(&panel, &cx);
5002
5003 panel.read_with(&cx, |panel, _cx| {
5004 assert_eq!(panel.background_threads.len(), 1);
5005 assert!(panel.background_threads.contains_key(&session_id_a));
5006 });
5007
5008 // Load thread A back via load_agent_thread — should promote from background.
5009 panel.update_in(&mut cx, |panel, window, cx| {
5010 panel.load_agent_thread(session_id_a.clone(), None, None, window, cx);
5011 });
5012
5013 // Thread A should now be the active view, promoted from background.
5014 let active_session = active_session_id(&panel, &cx);
5015 assert_eq!(
5016 active_session, session_id_a,
5017 "Thread A should be the active thread after promotion"
5018 );
5019
5020 panel.read_with(&cx, |panel, _cx| {
5021 assert!(
5022 !panel.background_threads.contains_key(&session_id_a),
5023 "Promoted thread A should no longer be in background_views"
5024 );
5025 assert!(
5026 !panel.background_threads.contains_key(&session_id_b),
5027 "Thread B (idle) should not have been retained in background_views"
5028 );
5029 });
5030 }
5031
5032 #[gpui::test]
5033 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5034 init_test(cx);
5035 cx.update(|cx| {
5036 cx.update_flags(true, vec!["agent-v2".to_string()]);
5037 agent::ThreadStore::init_global(cx);
5038 language_model::LanguageModelRegistry::test(cx);
5039 });
5040
5041 let fs = FakeFs::new(cx.executor());
5042 fs.insert_tree(
5043 "/project",
5044 json!({
5045 ".git": {},
5046 "src": {
5047 "main.rs": "fn main() {}"
5048 }
5049 }),
5050 )
5051 .await;
5052 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5053
5054 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5055
5056 let multi_workspace =
5057 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5058
5059 let workspace = multi_workspace
5060 .read_with(cx, |multi_workspace, _cx| {
5061 multi_workspace.workspace().clone()
5062 })
5063 .unwrap();
5064
5065 workspace.update(cx, |workspace, _cx| {
5066 workspace.set_random_database_id();
5067 });
5068
5069 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5070
5071 // Wait for the project to discover the git repository.
5072 cx.run_until_parked();
5073
5074 let panel = workspace.update_in(cx, |workspace, window, cx| {
5075 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5076 let panel =
5077 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
5078 workspace.add_panel(panel.clone(), window, cx);
5079 panel
5080 });
5081
5082 cx.run_until_parked();
5083
5084 // Default thread target should be LocalProject.
5085 panel.read_with(cx, |panel, _cx| {
5086 assert_eq!(
5087 *panel.start_thread_in(),
5088 StartThreadIn::LocalProject,
5089 "default thread target should be LocalProject"
5090 );
5091 });
5092
5093 // Start a new thread with the default LocalProject target.
5094 // Use StubAgentServer so the thread connects immediately in tests.
5095 panel.update_in(cx, |panel, window, cx| {
5096 panel.open_external_thread_with_server(
5097 Rc::new(StubAgentServer::default_response()),
5098 window,
5099 cx,
5100 );
5101 });
5102
5103 cx.run_until_parked();
5104
5105 // MultiWorkspace should still have exactly one workspace (no worktree created).
5106 multi_workspace
5107 .read_with(cx, |multi_workspace, _cx| {
5108 assert_eq!(
5109 multi_workspace.workspaces().len(),
5110 1,
5111 "LocalProject should not create a new workspace"
5112 );
5113 })
5114 .unwrap();
5115
5116 // The thread should be active in the panel.
5117 panel.read_with(cx, |panel, cx| {
5118 assert!(
5119 panel.active_agent_thread(cx).is_some(),
5120 "a thread should be running in the current workspace"
5121 );
5122 });
5123
5124 // The thread target should still be LocalProject (unchanged).
5125 panel.read_with(cx, |panel, _cx| {
5126 assert_eq!(
5127 *panel.start_thread_in(),
5128 StartThreadIn::LocalProject,
5129 "thread target should remain LocalProject"
5130 );
5131 });
5132
5133 // No worktree creation status should be set.
5134 panel.read_with(cx, |panel, _cx| {
5135 assert!(
5136 panel.worktree_creation_status.is_none(),
5137 "no worktree creation should have occurred"
5138 );
5139 });
5140 }
5141
5142 #[gpui::test]
5143 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5144 init_test(cx);
5145 cx.update(|cx| {
5146 cx.update_flags(true, vec!["agent-v2".to_string()]);
5147 agent::ThreadStore::init_global(cx);
5148 language_model::LanguageModelRegistry::test(cx);
5149 });
5150
5151 let fs = FakeFs::new(cx.executor());
5152 fs.insert_tree(
5153 "/project",
5154 json!({
5155 ".git": {},
5156 "src": {
5157 "main.rs": "fn main() {}"
5158 }
5159 }),
5160 )
5161 .await;
5162 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5163
5164 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5165
5166 let multi_workspace =
5167 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5168
5169 let workspace = multi_workspace
5170 .read_with(cx, |multi_workspace, _cx| {
5171 multi_workspace.workspace().clone()
5172 })
5173 .unwrap();
5174
5175 workspace.update(cx, |workspace, _cx| {
5176 workspace.set_random_database_id();
5177 });
5178
5179 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5180
5181 // Wait for the project to discover the git repository.
5182 cx.run_until_parked();
5183
5184 let panel = workspace.update_in(cx, |workspace, window, cx| {
5185 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5186 let panel =
5187 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
5188 workspace.add_panel(panel.clone(), window, cx);
5189 panel
5190 });
5191
5192 cx.run_until_parked();
5193
5194 // Default should be LocalProject.
5195 panel.read_with(cx, |panel, _cx| {
5196 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5197 });
5198
5199 // Change thread target to NewWorktree.
5200 panel.update(cx, |panel, cx| {
5201 panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx);
5202 });
5203
5204 panel.read_with(cx, |panel, _cx| {
5205 assert_eq!(
5206 *panel.start_thread_in(),
5207 StartThreadIn::NewWorktree,
5208 "thread target should be NewWorktree after set_thread_target"
5209 );
5210 });
5211
5212 // Let serialization complete.
5213 cx.run_until_parked();
5214
5215 // Load a fresh panel from the serialized data.
5216 let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
5217 let async_cx = cx.update(|window, cx| window.to_async(cx));
5218 let loaded_panel =
5219 AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx)
5220 .await
5221 .expect("panel load should succeed");
5222 cx.run_until_parked();
5223
5224 loaded_panel.read_with(cx, |panel, _cx| {
5225 assert_eq!(
5226 *panel.start_thread_in(),
5227 StartThreadIn::NewWorktree,
5228 "thread target should survive serialization round-trip"
5229 );
5230 });
5231 }
5232
5233 #[gpui::test]
5234 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
5235 init_test(cx);
5236
5237 let fs = FakeFs::new(cx.executor());
5238 cx.update(|cx| {
5239 cx.update_flags(true, vec!["agent-v2".to_string()]);
5240 agent::ThreadStore::init_global(cx);
5241 language_model::LanguageModelRegistry::test(cx);
5242 <dyn fs::Fs>::set_global(fs.clone(), cx);
5243 });
5244
5245 fs.insert_tree(
5246 "/project",
5247 json!({
5248 ".git": {},
5249 "src": {
5250 "main.rs": "fn main() {}"
5251 }
5252 }),
5253 )
5254 .await;
5255
5256 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5257
5258 let multi_workspace =
5259 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5260
5261 let workspace = multi_workspace
5262 .read_with(cx, |multi_workspace, _cx| {
5263 multi_workspace.workspace().clone()
5264 })
5265 .unwrap();
5266
5267 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5268
5269 let panel = workspace.update_in(cx, |workspace, window, cx| {
5270 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
5271 let panel =
5272 cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
5273 workspace.add_panel(panel.clone(), window, cx);
5274 panel
5275 });
5276
5277 cx.run_until_parked();
5278
5279 // Simulate worktree creation in progress and reset to Uninitialized
5280 panel.update_in(cx, |panel, window, cx| {
5281 panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
5282 panel.active_view = ActiveView::Uninitialized;
5283 Panel::set_active(panel, true, window, cx);
5284 assert!(
5285 matches!(panel.active_view, ActiveView::Uninitialized),
5286 "set_active should not create a thread while worktree is being created"
5287 );
5288 });
5289
5290 // Clear the creation status and use open_external_thread_with_server
5291 // (which bypasses new_agent_thread) to verify the panel can transition
5292 // out of Uninitialized. We can't call set_active directly because
5293 // new_agent_thread requires full agent server infrastructure.
5294 panel.update_in(cx, |panel, window, cx| {
5295 panel.worktree_creation_status = None;
5296 panel.active_view = ActiveView::Uninitialized;
5297 panel.open_external_thread_with_server(
5298 Rc::new(StubAgentServer::default_response()),
5299 window,
5300 cx,
5301 );
5302 });
5303
5304 cx.run_until_parked();
5305
5306 panel.read_with(cx, |panel, _cx| {
5307 assert!(
5308 !matches!(panel.active_view, ActiveView::Uninitialized),
5309 "panel should transition out of Uninitialized once worktree creation is cleared"
5310 );
5311 });
5312 }
5313}