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