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