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