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