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