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