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