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