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