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