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