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