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