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