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