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