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