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, modal_workspace) =
3194 window_handle.update(cx, |multi_workspace, window, cx| {
3195 let path_list = PathList::new(&all_paths);
3196 let active_workspace = multi_workspace.workspace().clone();
3197 let modal_workspace = active_workspace.clone();
3198
3199 let task = multi_workspace.find_or_create_workspace(
3200 path_list,
3201 remote_connection_options,
3202 None,
3203 move |connection_options, window, cx| {
3204 remote_connection::connect_with_modal(
3205 &active_workspace,
3206 connection_options,
3207 window,
3208 cx,
3209 )
3210 },
3211 window,
3212 cx,
3213 );
3214 (task, modal_workspace)
3215 })?;
3216
3217 let result = workspace_task.await;
3218 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
3219 let new_workspace = result?;
3220
3221 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
3222
3223 if let Some(task) = panels_task {
3224 task.await.log_err();
3225 }
3226
3227 new_workspace
3228 .update(cx, |workspace, cx| {
3229 workspace.project().read(cx).wait_for_initial_scan(cx)
3230 })
3231 .await;
3232
3233 new_workspace
3234 .update(cx, |workspace, cx| {
3235 let repos = workspace
3236 .project()
3237 .read(cx)
3238 .repositories(cx)
3239 .values()
3240 .cloned()
3241 .collect::<Vec<_>>();
3242
3243 let tasks = repos
3244 .into_iter()
3245 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
3246 futures::future::join_all(tasks)
3247 })
3248 .await;
3249
3250 let initial_content = AgentInitialContent::ContentBlock {
3251 blocks: content,
3252 auto_submit: true,
3253 };
3254
3255 window_handle.update(cx, |_multi_workspace, window, cx| {
3256 new_workspace.update(cx, |workspace, cx| {
3257 if has_non_git {
3258 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
3259 workspace.show_toast(
3260 workspace::Toast::new(
3261 toast_id,
3262 "Some project folders are not git repositories. \
3263 They were included as-is without creating a worktree.",
3264 ),
3265 cx,
3266 );
3267 }
3268
3269 // If we had an active buffer, remap its path and reopen it.
3270 let had_active_file = active_file_path.is_some();
3271 let remapped_active_path = active_file_path.and_then(|original_path| {
3272 let best_match = path_remapping
3273 .iter()
3274 .filter_map(|(old_root, new_root)| {
3275 original_path.strip_prefix(old_root).ok().map(|relative| {
3276 (old_root.components().count(), new_root.join(relative))
3277 })
3278 })
3279 .max_by_key(|(depth, _)| *depth);
3280
3281 if let Some((_, remapped_path)) = best_match {
3282 return Some(remapped_path);
3283 }
3284
3285 for non_git in &non_git_paths {
3286 if original_path.starts_with(non_git) {
3287 return Some(original_path);
3288 }
3289 }
3290 None
3291 });
3292
3293 if had_active_file && remapped_active_path.is_none() {
3294 log::warn!(
3295 "Active file could not be remapped to the new worktree; it will not be reopened"
3296 );
3297 }
3298
3299 if let Some(path) = remapped_active_path {
3300 let open_task = workspace.open_paths(
3301 vec![path],
3302 workspace::OpenOptions::default(),
3303 None,
3304 window,
3305 cx,
3306 );
3307 cx.spawn(async move |_, _| -> anyhow::Result<()> {
3308 for item in open_task.await.into_iter().flatten() {
3309 item?;
3310 }
3311 Ok(())
3312 })
3313 .detach_and_log_err(cx);
3314 }
3315
3316 workspace.focus_panel::<AgentPanel>(window, cx);
3317
3318 // If no active buffer was open, zoom the agent panel
3319 // (equivalent to cmd-esc fullscreen behavior).
3320 // This must happen after focus_panel, which activates
3321 // and opens the panel in the dock.
3322
3323 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3324 panel.update(cx, |panel, cx| {
3325 panel.external_thread(
3326 selected_agent,
3327 None,
3328 None,
3329 None,
3330 Some(initial_content),
3331 true,
3332 window,
3333 cx,
3334 );
3335 });
3336 }
3337 });
3338 })?;
3339
3340 window_handle.update(cx, |multi_workspace, window, cx| {
3341 multi_workspace.activate(new_workspace.clone(), window, cx);
3342
3343 new_workspace.update(cx, |workspace, cx| {
3344 workspace.run_create_worktree_tasks(window, cx);
3345 })
3346 })?;
3347
3348 this.update_in(cx, |this, window, cx| {
3349 this.worktree_creation_status = None;
3350
3351 if let Some(thread_view) = this.active_thread_view(cx) {
3352 thread_view.update(cx, |thread_view, cx| {
3353 thread_view
3354 .message_editor
3355 .update(cx, |editor, cx| editor.clear(window, cx));
3356 });
3357 }
3358
3359 this.start_thread_in = StartThreadIn::LocalProject;
3360 this.serialize(cx);
3361 cx.notify();
3362 })?;
3363
3364 anyhow::Ok(())
3365 }
3366}
3367
3368impl Focusable for AgentPanel {
3369 fn focus_handle(&self, cx: &App) -> FocusHandle {
3370 match &self.active_view {
3371 ActiveView::Uninitialized => self.focus_handle.clone(),
3372 ActiveView::AgentThread {
3373 conversation_view, ..
3374 } => conversation_view.focus_handle(cx),
3375 ActiveView::History { view } => view.read(cx).focus_handle(cx),
3376 ActiveView::Configuration => {
3377 if let Some(configuration) = self.configuration.as_ref() {
3378 configuration.focus_handle(cx)
3379 } else {
3380 self.focus_handle.clone()
3381 }
3382 }
3383 }
3384 }
3385}
3386
3387fn agent_panel_dock_position(cx: &App) -> DockPosition {
3388 AgentSettings::get_global(cx).dock.into()
3389}
3390
3391pub enum AgentPanelEvent {
3392 ActiveViewChanged,
3393 ThreadFocused,
3394 BackgroundThreadChanged,
3395 MessageSentOrQueued { session_id: acp::SessionId },
3396}
3397
3398impl EventEmitter<PanelEvent> for AgentPanel {}
3399impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3400
3401impl Panel for AgentPanel {
3402 fn persistent_name() -> &'static str {
3403 "AgentPanel"
3404 }
3405
3406 fn panel_key() -> &'static str {
3407 AGENT_PANEL_KEY
3408 }
3409
3410 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3411 agent_panel_dock_position(cx)
3412 }
3413
3414 fn position_is_valid(&self, position: DockPosition) -> bool {
3415 position != DockPosition::Bottom
3416 }
3417
3418 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
3419 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3420 settings
3421 .agent
3422 .get_or_insert_default()
3423 .set_dock(position.into());
3424 });
3425 }
3426
3427 fn default_size(&self, window: &Window, cx: &App) -> Pixels {
3428 let settings = AgentSettings::get_global(cx);
3429 match self.position(window, cx) {
3430 DockPosition::Left | DockPosition::Right => settings.default_width,
3431 DockPosition::Bottom => settings.default_height,
3432 }
3433 }
3434
3435 fn supports_flexible_size(&self) -> bool {
3436 true
3437 }
3438
3439 fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
3440 AgentSettings::get_global(cx).flexible
3441 }
3442
3443 fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
3444 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3445 settings
3446 .agent
3447 .get_or_insert_default()
3448 .set_flexible_size(flexible);
3449 });
3450 }
3451
3452 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
3453 if active
3454 && matches!(self.active_view, ActiveView::Uninitialized)
3455 && !matches!(
3456 self.worktree_creation_status,
3457 Some((_, WorktreeCreationStatus::Creating))
3458 )
3459 {
3460 let selected_agent = self.selected_agent.clone();
3461 self.new_agent_thread_inner(selected_agent, false, window, cx);
3462 }
3463 }
3464
3465 fn remote_id() -> Option<proto::PanelId> {
3466 Some(proto::PanelId::AssistantPanel)
3467 }
3468
3469 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3470 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3471 }
3472
3473 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3474 Some("Agent Panel")
3475 }
3476
3477 fn toggle_action(&self) -> Box<dyn Action> {
3478 Box::new(ToggleFocus)
3479 }
3480
3481 fn activation_priority(&self) -> u32 {
3482 0
3483 }
3484
3485 fn enabled(&self, cx: &App) -> bool {
3486 AgentSettings::get_global(cx).enabled(cx)
3487 }
3488
3489 fn is_agent_panel(&self) -> bool {
3490 true
3491 }
3492
3493 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3494 self.zoomed
3495 }
3496
3497 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3498 self.zoomed = zoomed;
3499 cx.notify();
3500 }
3501}
3502
3503impl AgentPanel {
3504 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3505 let content = match &self.active_view {
3506 ActiveView::AgentThread { conversation_view } => {
3507 let server_view_ref = conversation_view.read(cx);
3508 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3509 && server_view_ref.root_thread(cx).map_or(false, |tv| {
3510 tv.read(cx).thread.read(cx).has_provisional_title()
3511 });
3512
3513 if let Some(title_editor) = server_view_ref
3514 .root_thread(cx)
3515 .map(|r| r.read(cx).title_editor.clone())
3516 {
3517 if is_generating_title {
3518 Label::new(DEFAULT_THREAD_TITLE)
3519 .color(Color::Muted)
3520 .truncate()
3521 .with_animation(
3522 "generating_title",
3523 Animation::new(Duration::from_secs(2))
3524 .repeat()
3525 .with_easing(pulsating_between(0.4, 0.8)),
3526 |label, delta| label.alpha(delta),
3527 )
3528 .into_any_element()
3529 } else {
3530 div()
3531 .w_full()
3532 .on_action({
3533 let conversation_view = conversation_view.downgrade();
3534 move |_: &menu::Confirm, window, cx| {
3535 if let Some(conversation_view) = conversation_view.upgrade() {
3536 conversation_view.focus_handle(cx).focus(window, cx);
3537 }
3538 }
3539 })
3540 .on_action({
3541 let conversation_view = conversation_view.downgrade();
3542 move |_: &editor::actions::Cancel, window, cx| {
3543 if let Some(conversation_view) = conversation_view.upgrade() {
3544 conversation_view.focus_handle(cx).focus(window, cx);
3545 }
3546 }
3547 })
3548 .child(title_editor)
3549 .into_any_element()
3550 }
3551 } else {
3552 Label::new(conversation_view.read(cx).title(cx))
3553 .color(Color::Muted)
3554 .truncate()
3555 .into_any_element()
3556 }
3557 }
3558 ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
3559 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
3560 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3561 };
3562
3563 h_flex()
3564 .key_context("TitleEditor")
3565 .id("TitleEditor")
3566 .flex_grow()
3567 .w_full()
3568 .max_w_full()
3569 .overflow_x_scroll()
3570 .child(content)
3571 .into_any()
3572 }
3573
3574 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3575 conversation_view.update(cx, |conversation_view, cx| {
3576 if let Some(thread) = conversation_view.as_native_thread(cx) {
3577 thread.update(cx, |thread, cx| {
3578 thread.generate_title(cx);
3579 });
3580 }
3581 });
3582 }
3583
3584 fn render_panel_options_menu(
3585 &self,
3586 _window: &mut Window,
3587 cx: &mut Context<Self>,
3588 ) -> impl IntoElement {
3589 let focus_handle = self.focus_handle(cx);
3590
3591 let conversation_view = match &self.active_view {
3592 ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3593 _ => None,
3594 };
3595 let thread_with_messages = match &self.active_view {
3596 ActiveView::AgentThread { conversation_view } => {
3597 conversation_view.read(cx).has_user_submitted_prompt(cx)
3598 }
3599 _ => false,
3600 };
3601 let has_auth_methods = match &self.active_view {
3602 ActiveView::AgentThread { conversation_view } => {
3603 conversation_view.read(cx).has_auth_methods()
3604 }
3605 _ => false,
3606 };
3607
3608 PopoverMenu::new("agent-options-menu")
3609 .trigger_with_tooltip(
3610 IconButton::new("agent-options-menu", IconName::Ellipsis)
3611 .icon_size(IconSize::Small),
3612 {
3613 let focus_handle = focus_handle.clone();
3614 move |_window, cx| {
3615 Tooltip::for_action_in(
3616 "Toggle Agent Menu",
3617 &ToggleOptionsMenu,
3618 &focus_handle,
3619 cx,
3620 )
3621 }
3622 },
3623 )
3624 .anchor(Corner::TopRight)
3625 .with_handle(self.agent_panel_menu_handle.clone())
3626 .menu({
3627 move |window, cx| {
3628 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3629 menu = menu.context(focus_handle.clone());
3630
3631 if thread_with_messages {
3632 menu = menu.header("Current Thread");
3633
3634 if let Some(conversation_view) = conversation_view.as_ref() {
3635 menu = menu
3636 .entry("Regenerate Thread Title", None, {
3637 let conversation_view = conversation_view.clone();
3638 move |_, cx| {
3639 Self::handle_regenerate_thread_title(
3640 conversation_view.clone(),
3641 cx,
3642 );
3643 }
3644 })
3645 .separator();
3646 }
3647 }
3648
3649 menu = menu
3650 .header("MCP Servers")
3651 .action(
3652 "View Server Extensions",
3653 Box::new(zed_actions::Extensions {
3654 category_filter: Some(
3655 zed_actions::ExtensionCategoryFilter::ContextServers,
3656 ),
3657 id: None,
3658 }),
3659 )
3660 .action("Add Custom Server…", Box::new(AddContextServer))
3661 .separator()
3662 .action("Rules", Box::new(OpenRulesLibrary::default()))
3663 .action("Profiles", Box::new(ManageProfiles::default()))
3664 .action("Settings", Box::new(OpenSettings))
3665 .separator()
3666 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
3667
3668 if has_auth_methods {
3669 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3670 }
3671
3672 menu
3673 }))
3674 }
3675 })
3676 }
3677
3678 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3679 let focus_handle = self.focus_handle(cx);
3680
3681 IconButton::new("go-back", IconName::ArrowLeft)
3682 .icon_size(IconSize::Small)
3683 .on_click(cx.listener(|this, _, window, cx| {
3684 this.go_back(&workspace::GoBack, window, cx);
3685 }))
3686 .tooltip({
3687 move |_window, cx| {
3688 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3689 }
3690 })
3691 }
3692
3693 fn project_has_git_repository(&self, cx: &App) -> bool {
3694 !self.project.read(cx).repositories(cx).is_empty()
3695 }
3696
3697 fn is_active_view_creating_worktree(&self, _cx: &App) -> bool {
3698 match &self.worktree_creation_status {
3699 Some((view_id, WorktreeCreationStatus::Creating)) => {
3700 self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id)
3701 }
3702 _ => false,
3703 }
3704 }
3705
3706 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3707 let focus_handle = self.focus_handle(cx);
3708
3709 let is_creating = self.is_active_view_creating_worktree(cx);
3710
3711 let trigger_parts = self
3712 .start_thread_in
3713 .trigger_label(self.project.read(cx), cx);
3714
3715 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3716 IconName::ChevronUp
3717 } else {
3718 IconName::ChevronDown
3719 };
3720
3721 let trigger_button = ButtonLike::new("thread-target-trigger")
3722 .disabled(is_creating)
3723 .when_some(trigger_parts.prefix, |this, prefix| {
3724 this.child(Label::new(prefix).color(Color::Muted))
3725 })
3726 .child(Label::new(trigger_parts.label))
3727 .when_some(trigger_parts.suffix, |this, suffix| {
3728 this.child(Label::new(suffix).color(Color::Muted))
3729 })
3730 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3731
3732 let project = self.project.clone();
3733 let current_target = self.start_thread_in.clone();
3734 let fs = self.fs.clone();
3735
3736 PopoverMenu::new("thread-target-selector")
3737 .trigger_with_tooltip(trigger_button, {
3738 move |_window, cx| {
3739 Tooltip::for_action_in(
3740 "Start Thread In…",
3741 &CycleStartThreadIn,
3742 &focus_handle,
3743 cx,
3744 )
3745 }
3746 })
3747 .menu(move |window, cx| {
3748 let fs = fs.clone();
3749 Some(cx.new(|cx| {
3750 ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
3751 }))
3752 })
3753 .with_handle(self.start_thread_in_menu_handle.clone())
3754 .anchor(Corner::TopLeft)
3755 .offset(gpui::Point {
3756 x: px(1.0),
3757 y: px(1.0),
3758 })
3759 }
3760
3761 fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3762 let is_creating = self.is_active_view_creating_worktree(cx);
3763
3764 let project_ref = self.project.read(cx);
3765 let trigger_parts = self
3766 .start_thread_in
3767 .branch_trigger_label(project_ref, cx)
3768 .unwrap_or_else(|| StartThreadInLabel {
3769 prefix: Some("From:".into()),
3770 label: "HEAD".into(),
3771 suffix: None,
3772 });
3773
3774 let icon = if self.thread_branch_menu_handle.is_deployed() {
3775 IconName::ChevronUp
3776 } else {
3777 IconName::ChevronDown
3778 };
3779
3780 let trigger_button = ButtonLike::new("thread-branch-trigger")
3781 .disabled(is_creating)
3782 .when_some(trigger_parts.prefix, |this, prefix| {
3783 this.child(Label::new(prefix).color(Color::Muted))
3784 })
3785 .child(Label::new(trigger_parts.label))
3786 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3787
3788 let project = self.project.clone();
3789 let current_target = self.start_thread_in.clone();
3790
3791 PopoverMenu::new("thread-branch-selector")
3792 .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
3793 .menu(move |window, cx| {
3794 Some(cx.new(|cx| {
3795 ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
3796 }))
3797 })
3798 .with_handle(self.thread_branch_menu_handle.clone())
3799 .anchor(Corner::TopLeft)
3800 .offset(gpui::Point {
3801 x: px(1.0),
3802 y: px(1.0),
3803 })
3804 }
3805
3806 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3807 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3808 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3809 let focus_handle = self.focus_handle(cx);
3810
3811 let (selected_agent_custom_icon, selected_agent_label) =
3812 if let Agent::Custom { id, .. } = &self.selected_agent {
3813 let store = agent_server_store.read(cx);
3814 let icon = store.agent_icon(&id);
3815
3816 let label = store
3817 .agent_display_name(&id)
3818 .unwrap_or_else(|| self.selected_agent.label());
3819 (icon, label)
3820 } else {
3821 (None, self.selected_agent.label())
3822 };
3823
3824 let active_thread = match &self.active_view {
3825 ActiveView::AgentThread { conversation_view } => {
3826 conversation_view.read(cx).as_native_thread(cx)
3827 }
3828 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3829 None
3830 }
3831 };
3832
3833 let new_thread_menu_builder: Rc<
3834 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3835 > = {
3836 let selected_agent = self.selected_agent.clone();
3837 let is_agent_selected = move |agent: Agent| selected_agent == agent;
3838
3839 let workspace = self.workspace.clone();
3840 let is_via_collab = workspace
3841 .update(cx, |workspace, cx| {
3842 workspace.project().read(cx).is_via_collab()
3843 })
3844 .unwrap_or_default();
3845
3846 let focus_handle = focus_handle.clone();
3847 let agent_server_store = agent_server_store;
3848
3849 Rc::new(move |window, cx| {
3850 telemetry::event!("New Thread Clicked");
3851
3852 let active_thread = active_thread.clone();
3853 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3854 menu.context(focus_handle.clone())
3855 .when_some(active_thread, |this, active_thread| {
3856 let thread = active_thread.read(cx);
3857
3858 if !thread.is_empty() {
3859 let session_id = thread.id().clone();
3860 this.item(
3861 ContextMenuEntry::new("New From Summary")
3862 .icon(IconName::ThreadFromSummary)
3863 .icon_color(Color::Muted)
3864 .handler(move |window, cx| {
3865 window.dispatch_action(
3866 Box::new(NewNativeAgentThreadFromSummary {
3867 from_session_id: session_id.clone(),
3868 }),
3869 cx,
3870 );
3871 }),
3872 )
3873 } else {
3874 this
3875 }
3876 })
3877 .item(
3878 ContextMenuEntry::new("Zed Agent")
3879 .when(is_agent_selected(Agent::NativeAgent), |this| {
3880 this.action(Box::new(NewExternalAgentThread { agent: None }))
3881 })
3882 .icon(IconName::ZedAgent)
3883 .icon_color(Color::Muted)
3884 .handler({
3885 let workspace = workspace.clone();
3886 move |window, cx| {
3887 if let Some(workspace) = workspace.upgrade() {
3888 workspace.update(cx, |workspace, cx| {
3889 if let Some(panel) =
3890 workspace.panel::<AgentPanel>(cx)
3891 {
3892 panel.update(cx, |panel, cx| {
3893 panel.new_agent_thread(
3894 Agent::NativeAgent,
3895 window,
3896 cx,
3897 );
3898 });
3899 }
3900 });
3901 }
3902 }
3903 }),
3904 )
3905 .map(|mut menu| {
3906 let agent_server_store = agent_server_store.read(cx);
3907 let registry_store = project::AgentRegistryStore::try_global(cx);
3908 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
3909
3910 struct AgentMenuItem {
3911 id: AgentId,
3912 display_name: SharedString,
3913 }
3914
3915 let agent_items = agent_server_store
3916 .external_agents()
3917 .map(|agent_id| {
3918 let display_name = agent_server_store
3919 .agent_display_name(agent_id)
3920 .or_else(|| {
3921 registry_store_ref
3922 .as_ref()
3923 .and_then(|store| store.agent(agent_id))
3924 .map(|a| a.name().clone())
3925 })
3926 .unwrap_or_else(|| agent_id.0.clone());
3927 AgentMenuItem {
3928 id: agent_id.clone(),
3929 display_name,
3930 }
3931 })
3932 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3933 .collect::<Vec<_>>();
3934
3935 if !agent_items.is_empty() {
3936 menu = menu.separator().header("External Agents");
3937 }
3938 for item in &agent_items {
3939 let mut entry = ContextMenuEntry::new(item.display_name.clone());
3940
3941 let icon_path =
3942 agent_server_store.agent_icon(&item.id).or_else(|| {
3943 registry_store_ref
3944 .as_ref()
3945 .and_then(|store| store.agent(&item.id))
3946 .and_then(|a| a.icon_path().cloned())
3947 });
3948
3949 if let Some(icon_path) = icon_path {
3950 entry = entry.custom_icon_svg(icon_path);
3951 } else {
3952 entry = entry.icon(IconName::Sparkle);
3953 }
3954
3955 entry = entry
3956 .when(
3957 is_agent_selected(Agent::Custom {
3958 id: item.id.clone(),
3959 }),
3960 |this| {
3961 this.action(Box::new(NewExternalAgentThread {
3962 agent: None,
3963 }))
3964 },
3965 )
3966 .icon_color(Color::Muted)
3967 .disabled(is_via_collab)
3968 .handler({
3969 let workspace = workspace.clone();
3970 let agent_id = item.id.clone();
3971 move |window, cx| {
3972 if let Some(workspace) = workspace.upgrade() {
3973 workspace.update(cx, |workspace, cx| {
3974 if let Some(panel) =
3975 workspace.panel::<AgentPanel>(cx)
3976 {
3977 panel.update(cx, |panel, cx| {
3978 panel.new_agent_thread(
3979 Agent::Custom {
3980 id: agent_id.clone(),
3981 },
3982 window,
3983 cx,
3984 );
3985 });
3986 }
3987 });
3988 }
3989 }
3990 });
3991
3992 menu = menu.item(entry);
3993 }
3994
3995 menu
3996 })
3997 .separator()
3998 .item(
3999 ContextMenuEntry::new("Add More Agents")
4000 .icon(IconName::Plus)
4001 .icon_color(Color::Muted)
4002 .handler({
4003 move |window, cx| {
4004 window
4005 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
4006 }
4007 }),
4008 )
4009 }))
4010 })
4011 };
4012
4013 let is_thread_loading = self
4014 .active_conversation_view()
4015 .map(|thread| thread.read(cx).is_loading())
4016 .unwrap_or(false);
4017
4018 let has_custom_icon = selected_agent_custom_icon.is_some();
4019 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
4020 let selected_agent_builtin_icon = self.selected_agent.icon();
4021 let selected_agent_label_for_tooltip = selected_agent_label.clone();
4022
4023 let selected_agent = div()
4024 .id("selected_agent_icon")
4025 .when_some(selected_agent_custom_icon, |this, icon_path| {
4026 this.px_1()
4027 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
4028 })
4029 .when(!has_custom_icon, |this| {
4030 this.when_some(selected_agent_builtin_icon, |this, icon| {
4031 this.px_1().child(Icon::new(icon).color(Color::Muted))
4032 })
4033 })
4034 .tooltip(move |_, cx| {
4035 Tooltip::with_meta(
4036 selected_agent_label_for_tooltip.clone(),
4037 None,
4038 "Selected Agent",
4039 cx,
4040 )
4041 });
4042
4043 let selected_agent = if is_thread_loading {
4044 selected_agent
4045 .with_animation(
4046 "pulsating-icon",
4047 Animation::new(Duration::from_secs(1))
4048 .repeat()
4049 .with_easing(pulsating_between(0.2, 0.6)),
4050 |icon, delta| icon.opacity(delta),
4051 )
4052 .into_any_element()
4053 } else {
4054 selected_agent.into_any_element()
4055 };
4056
4057 let is_empty_state = !self.active_thread_has_messages(cx);
4058
4059 let is_in_history_or_config = matches!(
4060 &self.active_view,
4061 ActiveView::History { .. } | ActiveView::Configuration
4062 );
4063
4064 let is_full_screen = self.is_zoomed(window, cx);
4065 let full_screen_button = if is_full_screen {
4066 IconButton::new("disable-full-screen", IconName::Minimize)
4067 .icon_size(IconSize::Small)
4068 .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
4069 .on_click(cx.listener(move |this, _, window, cx| {
4070 this.toggle_zoom(&ToggleZoom, window, cx);
4071 }))
4072 } else {
4073 IconButton::new("enable-full-screen", IconName::Maximize)
4074 .icon_size(IconSize::Small)
4075 .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
4076 .on_click(cx.listener(move |this, _, window, cx| {
4077 this.toggle_zoom(&ToggleZoom, window, cx);
4078 }))
4079 };
4080
4081 let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
4082
4083 let max_content_width = AgentSettings::get_global(cx).max_content_width;
4084
4085 let base_container = h_flex()
4086 .size_full()
4087 // TODO: This is only until we remove Agent settings from the panel.
4088 .when(!is_in_history_or_config, |this| {
4089 this.max_w(max_content_width).mx_auto()
4090 })
4091 .flex_none()
4092 .justify_between()
4093 .gap_2();
4094
4095 let toolbar_content = if use_v2_empty_toolbar {
4096 let (chevron_icon, icon_color, label_color) =
4097 if self.new_thread_menu_handle.is_deployed() {
4098 (IconName::ChevronUp, Color::Accent, Color::Accent)
4099 } else {
4100 (IconName::ChevronDown, Color::Muted, Color::Default)
4101 };
4102
4103 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
4104 Icon::from_external_svg(icon_path)
4105 .size(IconSize::Small)
4106 .color(icon_color)
4107 } else {
4108 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
4109 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
4110 };
4111
4112 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4113 .start_icon(agent_icon)
4114 .color(label_color)
4115 .end_icon(
4116 Icon::new(chevron_icon)
4117 .color(icon_color)
4118 .size(IconSize::XSmall),
4119 );
4120
4121 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4122 .trigger_with_tooltip(agent_selector_button, {
4123 move |_window, cx| {
4124 Tooltip::for_action_in(
4125 "New Thread…",
4126 &ToggleNewThreadMenu,
4127 &focus_handle,
4128 cx,
4129 )
4130 }
4131 })
4132 .menu({
4133 let builder = new_thread_menu_builder.clone();
4134 move |window, cx| builder(window, cx)
4135 })
4136 .with_handle(self.new_thread_menu_handle.clone())
4137 .anchor(Corner::TopLeft)
4138 .offset(gpui::Point {
4139 x: px(1.0),
4140 y: px(1.0),
4141 });
4142
4143 base_container
4144 .child(
4145 h_flex()
4146 .size_full()
4147 .gap(DynamicSpacing::Base04.rems(cx))
4148 .pl(DynamicSpacing::Base04.rems(cx))
4149 .child(agent_selector_menu)
4150 .when(
4151 has_visible_worktrees && self.project_has_git_repository(cx),
4152 |this| this.child(self.render_start_thread_in_selector(cx)),
4153 )
4154 .when(
4155 matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
4156 |this| this.child(self.render_new_worktree_branch_selector(cx)),
4157 ),
4158 )
4159 .child(
4160 h_flex()
4161 .h_full()
4162 .flex_none()
4163 .gap_1()
4164 .pl_1()
4165 .pr_1()
4166 .child(full_screen_button)
4167 .child(self.render_panel_options_menu(window, cx)),
4168 )
4169 .into_any_element()
4170 } else {
4171 let new_thread_menu = PopoverMenu::new("new_thread_menu")
4172 .trigger_with_tooltip(
4173 IconButton::new("new_thread_menu_btn", IconName::Plus)
4174 .icon_size(IconSize::Small),
4175 {
4176 move |_window, cx| {
4177 Tooltip::for_action_in(
4178 "New Thread\u{2026}",
4179 &ToggleNewThreadMenu,
4180 &focus_handle,
4181 cx,
4182 )
4183 }
4184 },
4185 )
4186 .anchor(Corner::TopRight)
4187 .with_handle(self.new_thread_menu_handle.clone())
4188 .menu(move |window, cx| new_thread_menu_builder(window, cx));
4189
4190 base_container
4191 .child(
4192 h_flex()
4193 .size_full()
4194 .gap(DynamicSpacing::Base04.rems(cx))
4195 .pl(DynamicSpacing::Base04.rems(cx))
4196 .child(match &self.active_view {
4197 ActiveView::History { .. } | ActiveView::Configuration => {
4198 self.render_toolbar_back_button(cx).into_any_element()
4199 }
4200 _ => selected_agent.into_any_element(),
4201 })
4202 .child(self.render_title_view(window, cx)),
4203 )
4204 .child(
4205 h_flex()
4206 .h_full()
4207 .flex_none()
4208 .gap_1()
4209 .pl_1()
4210 .pr_1()
4211 .child(new_thread_menu)
4212 .child(full_screen_button)
4213 .child(self.render_panel_options_menu(window, cx)),
4214 )
4215 .into_any_element()
4216 };
4217
4218 h_flex()
4219 .id("agent-panel-toolbar")
4220 .h(Tab::container_height(cx))
4221 .flex_shrink_0()
4222 .max_w_full()
4223 .bg(cx.theme().colors().tab_bar_background)
4224 .border_b_1()
4225 .border_color(cx.theme().colors().border)
4226 .child(toolbar_content)
4227 }
4228
4229 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4230 let (view_id, status) = self.worktree_creation_status.as_ref()?;
4231 let active_view_id = self.active_conversation_view().map(|v| v.entity_id());
4232 if active_view_id != Some(*view_id) {
4233 return None;
4234 }
4235 match status {
4236 WorktreeCreationStatus::Creating => Some(
4237 h_flex()
4238 .absolute()
4239 .bottom_12()
4240 .w_full()
4241 .p_2()
4242 .gap_1()
4243 .justify_center()
4244 .bg(cx.theme().colors().editor_background)
4245 .child(
4246 Icon::new(IconName::LoadCircle)
4247 .size(IconSize::Small)
4248 .color(Color::Muted)
4249 .with_rotate_animation(3),
4250 )
4251 .child(
4252 Label::new("Creating Worktree…")
4253 .color(Color::Muted)
4254 .size(LabelSize::Small),
4255 )
4256 .into_any_element(),
4257 ),
4258 WorktreeCreationStatus::Error(message) => Some(
4259 Callout::new()
4260 .icon(IconName::XCircleFilled)
4261 .severity(Severity::Error)
4262 .title("Worktree Creation Error")
4263 .description(message.clone())
4264 .border_position(ui::BorderPosition::Bottom)
4265 .dismiss_action(
4266 IconButton::new("dismiss-worktree-error", IconName::Close)
4267 .icon_size(IconSize::Small)
4268 .tooltip(Tooltip::text("Dismiss"))
4269 .on_click(cx.listener(|this, _, _, cx| {
4270 this.worktree_creation_status = None;
4271 cx.notify();
4272 })),
4273 )
4274 .into_any_element(),
4275 ),
4276 }
4277 }
4278
4279 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4280 if TrialEndUpsell::dismissed(cx) {
4281 return false;
4282 }
4283
4284 match &self.active_view {
4285 ActiveView::AgentThread { .. } => {
4286 if LanguageModelRegistry::global(cx)
4287 .read(cx)
4288 .default_model()
4289 .is_some_and(|model| {
4290 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4291 })
4292 {
4293 return false;
4294 }
4295 }
4296 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4297 return false;
4298 }
4299 }
4300
4301 let plan = self.user_store.read(cx).plan();
4302 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4303
4304 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4305 }
4306
4307 fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
4308 // We only want to show this for existing users: those who
4309 // have used the agent panel before the sidebar was introduced.
4310 // We can infer that state by users having seen the onboarding
4311 // at one point, but not the agent layout onboarding.
4312
4313 let has_messages = self.active_thread_has_messages(cx);
4314 let is_dismissed = self
4315 .agent_layout_onboarding_dismissed
4316 .load(Ordering::Acquire);
4317
4318 if is_dismissed || has_messages {
4319 return false;
4320 }
4321
4322 match &self.active_view {
4323 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4324 false
4325 }
4326 ActiveView::AgentThread { .. } => {
4327 let existing_user = self
4328 .new_user_onboarding_upsell_dismissed
4329 .load(Ordering::Acquire);
4330 existing_user
4331 }
4332 }
4333 }
4334
4335 fn render_agent_layout_onboarding(
4336 &self,
4337 _window: &mut Window,
4338 cx: &mut Context<Self>,
4339 ) -> Option<impl IntoElement> {
4340 if !self.should_render_agent_layout_onboarding(cx) {
4341 return None;
4342 }
4343
4344 Some(div().child(self.agent_layout_onboarding.clone()))
4345 }
4346
4347 fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
4348 self.agent_layout_onboarding_dismissed
4349 .store(true, Ordering::Release);
4350 AgentLayoutOnboarding::set_dismissed(true, cx);
4351 cx.notify();
4352 }
4353
4354 fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
4355 self.new_user_onboarding_upsell_dismissed
4356 .store(true, Ordering::Release);
4357 OnboardingUpsell::set_dismissed(true, cx);
4358 self.dismiss_agent_layout_onboarding(cx);
4359 cx.notify();
4360 }
4361
4362 fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
4363 if self
4364 .new_user_onboarding_upsell_dismissed
4365 .load(Ordering::Acquire)
4366 {
4367 return false;
4368 }
4369
4370 let user_store = self.user_store.read(cx);
4371
4372 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4373 && user_store
4374 .subscription_period()
4375 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4376 .is_some_and(|date| date < chrono::Utc::now())
4377 {
4378 if !self
4379 .new_user_onboarding_upsell_dismissed
4380 .load(Ordering::Acquire)
4381 {
4382 self.dismiss_ai_onboarding(cx);
4383 }
4384 return false;
4385 }
4386
4387 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4388 .visible_providers()
4389 .iter()
4390 .any(|provider| {
4391 provider.is_authenticated(cx)
4392 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4393 });
4394
4395 match &self.active_view {
4396 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4397 false
4398 }
4399 ActiveView::AgentThread {
4400 conversation_view, ..
4401 } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
4402 ActiveView::AgentThread { conversation_view } => {
4403 let history_is_empty = conversation_view
4404 .read(cx)
4405 .history()
4406 .is_none_or(|h| h.read(cx).is_empty());
4407 history_is_empty || !has_configured_non_zed_providers
4408 }
4409 }
4410 }
4411
4412 fn render_new_user_onboarding(
4413 &mut self,
4414 _window: &mut Window,
4415 cx: &mut Context<Self>,
4416 ) -> Option<impl IntoElement> {
4417 if !self.should_render_new_user_onboarding(cx) {
4418 return None;
4419 }
4420
4421 Some(
4422 div()
4423 .bg(cx.theme().colors().editor_background)
4424 .child(self.new_user_onboarding.clone()),
4425 )
4426 }
4427
4428 fn render_trial_end_upsell(
4429 &self,
4430 _window: &mut Window,
4431 cx: &mut Context<Self>,
4432 ) -> Option<impl IntoElement> {
4433 if !self.should_render_trial_end_upsell(cx) {
4434 return None;
4435 }
4436
4437 Some(
4438 v_flex()
4439 .absolute()
4440 .inset_0()
4441 .size_full()
4442 .bg(cx.theme().colors().panel_background)
4443 .opacity(0.85)
4444 .block_mouse_except_scroll()
4445 .child(EndTrialUpsell::new(Arc::new({
4446 let this = cx.entity();
4447 move |_, cx| {
4448 this.update(cx, |_this, cx| {
4449 TrialEndUpsell::set_dismissed(true, cx);
4450 cx.notify();
4451 });
4452 }
4453 }))),
4454 )
4455 }
4456
4457 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4458 let is_local = self.project.read(cx).is_local();
4459 div()
4460 .invisible()
4461 .absolute()
4462 .top_0()
4463 .right_0()
4464 .bottom_0()
4465 .left_0()
4466 .bg(cx.theme().colors().drop_target_background)
4467 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4468 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4469 .when(is_local, |this| {
4470 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4471 })
4472 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4473 let item = tab.pane.read(cx).item_for_index(tab.ix);
4474 let project_paths = item
4475 .and_then(|item| item.project_path(cx))
4476 .into_iter()
4477 .collect::<Vec<_>>();
4478 this.handle_drop(project_paths, vec![], window, cx);
4479 }))
4480 .on_drop(
4481 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4482 let project_paths = selection
4483 .items()
4484 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4485 .collect::<Vec<_>>();
4486 this.handle_drop(project_paths, vec![], window, cx);
4487 }),
4488 )
4489 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4490 let tasks = paths
4491 .paths()
4492 .iter()
4493 .map(|path| {
4494 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4495 })
4496 .collect::<Vec<_>>();
4497 cx.spawn_in(window, async move |this, cx| {
4498 let mut paths = vec![];
4499 let mut added_worktrees = vec![];
4500 let opened_paths = futures::future::join_all(tasks).await;
4501 for entry in opened_paths {
4502 if let Some((worktree, project_path)) = entry.log_err() {
4503 added_worktrees.push(worktree);
4504 paths.push(project_path);
4505 }
4506 }
4507 this.update_in(cx, |this, window, cx| {
4508 this.handle_drop(paths, added_worktrees, window, cx);
4509 })
4510 .ok();
4511 })
4512 .detach();
4513 }))
4514 }
4515
4516 fn handle_drop(
4517 &mut self,
4518 paths: Vec<ProjectPath>,
4519 added_worktrees: Vec<Entity<Worktree>>,
4520 window: &mut Window,
4521 cx: &mut Context<Self>,
4522 ) {
4523 match &self.active_view {
4524 ActiveView::AgentThread { conversation_view } => {
4525 conversation_view.update(cx, |conversation_view, cx| {
4526 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4527 });
4528 }
4529 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4530 }
4531 }
4532
4533 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4534 if !self.show_trust_workspace_message {
4535 return None;
4536 }
4537
4538 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4539
4540 Some(
4541 Callout::new()
4542 .icon(IconName::Warning)
4543 .severity(Severity::Warning)
4544 .border_position(ui::BorderPosition::Bottom)
4545 .title("You're in Restricted Mode")
4546 .description(description)
4547 .actions_slot(
4548 Button::new("open-trust-modal", "Configure Project Trust")
4549 .label_size(LabelSize::Small)
4550 .style(ButtonStyle::Outlined)
4551 .on_click({
4552 cx.listener(move |this, _, window, cx| {
4553 this.workspace
4554 .update(cx, |workspace, cx| {
4555 workspace
4556 .show_worktree_trust_security_modal(true, window, cx)
4557 })
4558 .log_err();
4559 })
4560 }),
4561 ),
4562 )
4563 }
4564
4565 fn key_context(&self) -> KeyContext {
4566 let mut key_context = KeyContext::new_with_defaults();
4567 key_context.add("AgentPanel");
4568 match &self.active_view {
4569 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4570 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4571 }
4572 key_context
4573 }
4574}
4575
4576impl Render for AgentPanel {
4577 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4578 // WARNING: Changes to this element hierarchy can have
4579 // non-obvious implications to the layout of children.
4580 //
4581 // If you need to change it, please confirm:
4582 // - The message editor expands (cmd-option-esc) correctly
4583 // - When expanded, the buttons at the bottom of the panel are displayed correctly
4584 // - Font size works as expected and can be changed with cmd-+/cmd-
4585 // - Scrolling in all views works as expected
4586 // - Files can be dropped into the panel
4587 let content = v_flex()
4588 .relative()
4589 .size_full()
4590 .justify_between()
4591 .key_context(self.key_context())
4592 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4593 this.new_thread(action, window, cx);
4594 }))
4595 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4596 this.open_history(window, cx);
4597 }))
4598 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4599 this.open_configuration(window, cx);
4600 }))
4601 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4602 .on_action(cx.listener(Self::deploy_rules_library))
4603 .on_action(cx.listener(Self::go_back))
4604 .on_action(cx.listener(Self::toggle_navigation_menu))
4605 .on_action(cx.listener(Self::toggle_options_menu))
4606 .on_action(cx.listener(Self::increase_font_size))
4607 .on_action(cx.listener(Self::decrease_font_size))
4608 .on_action(cx.listener(Self::reset_font_size))
4609 .on_action(cx.listener(Self::toggle_zoom))
4610 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4611 if let Some(conversation_view) = this.active_conversation_view() {
4612 conversation_view.update(cx, |conversation_view, cx| {
4613 conversation_view.reauthenticate(window, cx)
4614 })
4615 }
4616 }))
4617 .child(self.render_toolbar(window, cx))
4618 .children(self.render_workspace_trust_message(cx))
4619 .children(self.render_new_user_onboarding(window, cx))
4620 .children(self.render_agent_layout_onboarding(window, cx))
4621 .map(|parent| match &self.active_view {
4622 ActiveView::Uninitialized => parent,
4623 ActiveView::AgentThread {
4624 conversation_view, ..
4625 } => parent
4626 .child(conversation_view.clone())
4627 .child(self.render_drag_target(cx)),
4628 ActiveView::History { view } => parent.child(view.clone()),
4629 ActiveView::Configuration => parent.children(self.configuration.clone()),
4630 })
4631 .children(self.render_worktree_creation_status(cx))
4632 .children(self.render_trial_end_upsell(window, cx));
4633
4634 match self.active_view.which_font_size_used() {
4635 WhichFontSize::AgentFont => {
4636 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4637 .size_full()
4638 .child(content)
4639 .into_any()
4640 }
4641 _ => content.into_any(),
4642 }
4643 }
4644}
4645
4646struct PromptLibraryInlineAssist {
4647 workspace: WeakEntity<Workspace>,
4648}
4649
4650impl PromptLibraryInlineAssist {
4651 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4652 Self { workspace }
4653 }
4654}
4655
4656impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4657 fn assist(
4658 &self,
4659 prompt_editor: &Entity<Editor>,
4660 initial_prompt: Option<String>,
4661 window: &mut Window,
4662 cx: &mut Context<RulesLibrary>,
4663 ) {
4664 InlineAssistant::update_global(cx, |assistant, cx| {
4665 let Some(workspace) = self.workspace.upgrade() else {
4666 return;
4667 };
4668 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4669 return;
4670 };
4671 let history = panel
4672 .read(cx)
4673 .connection_store()
4674 .read(cx)
4675 .entry(&crate::Agent::NativeAgent)
4676 .and_then(|s| s.read(cx).history())
4677 .map(|h| h.downgrade());
4678 let project = workspace.read(cx).project().downgrade();
4679 let panel = panel.read(cx);
4680 let thread_store = panel.thread_store().clone();
4681 assistant.assist(
4682 prompt_editor,
4683 self.workspace.clone(),
4684 project,
4685 thread_store,
4686 None,
4687 history,
4688 initial_prompt,
4689 window,
4690 cx,
4691 );
4692 })
4693 }
4694
4695 fn focus_agent_panel(
4696 &self,
4697 workspace: &mut Workspace,
4698 window: &mut Window,
4699 cx: &mut Context<Workspace>,
4700 ) -> bool {
4701 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4702 }
4703}
4704
4705struct OnboardingUpsell;
4706
4707impl Dismissable for OnboardingUpsell {
4708 const KEY: &'static str = "dismissed-trial-upsell";
4709}
4710
4711struct AgentLayoutOnboarding;
4712
4713impl Dismissable for AgentLayoutOnboarding {
4714 const KEY: &'static str = "dismissed-agent-layout-onboarding";
4715}
4716
4717struct TrialEndUpsell;
4718
4719impl Dismissable for TrialEndUpsell {
4720 const KEY: &'static str = "dismissed-trial-end-upsell";
4721}
4722
4723/// Test-only helper methods
4724#[cfg(any(test, feature = "test-support"))]
4725impl AgentPanel {
4726 pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4727 Self::new(workspace, None, window, cx)
4728 }
4729
4730 /// Opens an external thread using an arbitrary AgentServer.
4731 ///
4732 /// This is a test-only helper that allows visual tests and integration tests
4733 /// to inject a stub server without modifying production code paths.
4734 /// Not compiled into production builds.
4735 pub fn open_external_thread_with_server(
4736 &mut self,
4737 server: Rc<dyn AgentServer>,
4738 window: &mut Window,
4739 cx: &mut Context<Self>,
4740 ) {
4741 let workspace = self.workspace.clone();
4742 let project = self.project.clone();
4743
4744 let ext_agent = Agent::Custom {
4745 id: server.agent_id(),
4746 };
4747
4748 self.create_agent_thread(
4749 server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
4750 );
4751 }
4752
4753 /// Returns the currently active thread view, if any.
4754 ///
4755 /// This is a test-only accessor that exposes the private `active_thread_view()`
4756 /// method for test assertions. Not compiled into production builds.
4757 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4758 self.active_conversation_view()
4759 }
4760
4761 /// Sets the start_thread_in value directly, bypassing validation.
4762 ///
4763 /// This is a test-only helper for visual tests that need to show specific
4764 /// start_thread_in states without requiring a real git repository.
4765 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4766 self.start_thread_in = target;
4767 cx.notify();
4768 }
4769
4770 /// Returns the current worktree creation status.
4771 ///
4772 /// This is a test-only helper for visual tests.
4773 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4774 self.worktree_creation_status.as_ref().map(|(_, s)| s)
4775 }
4776
4777 /// Sets the worktree creation status directly, associating it with the
4778 /// currently active conversation view.
4779 ///
4780 /// This is a test-only helper for visual tests that need to show the
4781 /// "Creating worktree…" spinner or error banners.
4782 pub fn set_worktree_creation_status_for_tests(
4783 &mut self,
4784 status: Option<WorktreeCreationStatus>,
4785 cx: &mut Context<Self>,
4786 ) {
4787 self.worktree_creation_status = status.map(|s| {
4788 let view_id = self
4789 .active_conversation_view()
4790 .map(|v| v.entity_id())
4791 .unwrap_or_else(|| EntityId::from(0u64));
4792 (view_id, s)
4793 });
4794 cx.notify();
4795 }
4796
4797 /// Opens the history view.
4798 ///
4799 /// This is a test-only helper that exposes the private `open_history()`
4800 /// method for visual tests.
4801 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4802 self.open_history(window, cx);
4803 }
4804
4805 /// Opens the start_thread_in selector popover menu.
4806 ///
4807 /// This is a test-only helper for visual tests.
4808 pub fn open_start_thread_in_menu_for_tests(
4809 &mut self,
4810 window: &mut Window,
4811 cx: &mut Context<Self>,
4812 ) {
4813 self.start_thread_in_menu_handle.show(window, cx);
4814 }
4815
4816 /// Dismisses the start_thread_in dropdown menu.
4817 ///
4818 /// This is a test-only helper for visual tests.
4819 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4820 self.start_thread_in_menu_handle.hide(cx);
4821 }
4822}
4823
4824#[cfg(test)]
4825mod tests {
4826 use super::*;
4827 use crate::conversation_view::tests::{StubAgentServer, init_test};
4828 use crate::test_support::{
4829 active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4830 send_message,
4831 };
4832 use acp_thread::{StubAgentConnection, ThreadStatus};
4833 use agent_servers::CODEX_ID;
4834 use feature_flags::FeatureFlagAppExt;
4835 use fs::FakeFs;
4836 use gpui::{TestAppContext, VisualTestContext};
4837 use project::Project;
4838 use serde_json::json;
4839 use std::path::Path;
4840 use std::time::Instant;
4841 use workspace::MultiWorkspace;
4842
4843 #[gpui::test]
4844 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4845 init_test(cx);
4846 cx.update(|cx| {
4847 agent::ThreadStore::init_global(cx);
4848 language_model::LanguageModelRegistry::test(cx);
4849 });
4850
4851 // Create a MultiWorkspace window with two workspaces.
4852 let fs = FakeFs::new(cx.executor());
4853 let project_a = Project::test(fs.clone(), [], cx).await;
4854 let project_b = Project::test(fs, [], cx).await;
4855
4856 let multi_workspace =
4857 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4858
4859 let workspace_a = multi_workspace
4860 .read_with(cx, |multi_workspace, _cx| {
4861 multi_workspace.workspace().clone()
4862 })
4863 .unwrap();
4864
4865 let workspace_b = multi_workspace
4866 .update(cx, |multi_workspace, window, cx| {
4867 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4868 })
4869 .unwrap();
4870
4871 workspace_a.update(cx, |workspace, _cx| {
4872 workspace.set_random_database_id();
4873 });
4874 workspace_b.update(cx, |workspace, _cx| {
4875 workspace.set_random_database_id();
4876 });
4877
4878 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4879
4880 // Set up workspace A: with an active thread.
4881 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4882 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4883 });
4884
4885 panel_a.update_in(cx, |panel, window, cx| {
4886 panel.open_external_thread_with_server(
4887 Rc::new(StubAgentServer::default_response()),
4888 window,
4889 cx,
4890 );
4891 });
4892
4893 cx.run_until_parked();
4894
4895 panel_a.read_with(cx, |panel, cx| {
4896 assert!(
4897 panel.active_agent_thread(cx).is_some(),
4898 "workspace A should have an active thread after connection"
4899 );
4900 });
4901
4902 send_message(&panel_a, cx);
4903
4904 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4905
4906 // Set up workspace B: ClaudeCode, no active thread.
4907 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4908 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4909 });
4910
4911 panel_b.update(cx, |panel, _cx| {
4912 panel.selected_agent = Agent::Custom {
4913 id: "claude-acp".into(),
4914 };
4915 });
4916
4917 // Serialize both panels.
4918 panel_a.update(cx, |panel, cx| panel.serialize(cx));
4919 panel_b.update(cx, |panel, cx| panel.serialize(cx));
4920 cx.run_until_parked();
4921
4922 // Load fresh panels for each workspace and verify independent state.
4923 let async_cx = cx.update(|window, cx| window.to_async(cx));
4924 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
4925 .await
4926 .expect("panel A load should succeed");
4927 cx.run_until_parked();
4928
4929 let async_cx = cx.update(|window, cx| window.to_async(cx));
4930 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
4931 .await
4932 .expect("panel B load should succeed");
4933 cx.run_until_parked();
4934
4935 // Workspace A should restore its thread and agent type
4936 loaded_a.read_with(cx, |panel, _cx| {
4937 assert_eq!(
4938 panel.selected_agent, agent_type_a,
4939 "workspace A agent type should be restored"
4940 );
4941 assert!(
4942 panel.active_conversation_view().is_some(),
4943 "workspace A should have its active thread restored"
4944 );
4945 });
4946
4947 // Workspace B should restore its own agent type, with no thread
4948 loaded_b.read_with(cx, |panel, _cx| {
4949 assert_eq!(
4950 panel.selected_agent,
4951 Agent::Custom {
4952 id: "claude-acp".into()
4953 },
4954 "workspace B agent type should be restored"
4955 );
4956 assert!(
4957 panel.active_conversation_view().is_none(),
4958 "workspace B should have no active thread"
4959 );
4960 });
4961 }
4962
4963 #[gpui::test]
4964 async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
4965 init_test(cx);
4966 cx.update(|cx| {
4967 agent::ThreadStore::init_global(cx);
4968 language_model::LanguageModelRegistry::test(cx);
4969 });
4970
4971 let fs = FakeFs::new(cx.executor());
4972 let project = Project::test(fs, [], cx).await;
4973
4974 let multi_workspace =
4975 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4976
4977 let workspace = multi_workspace
4978 .read_with(cx, |multi_workspace, _cx| {
4979 multi_workspace.workspace().clone()
4980 })
4981 .unwrap();
4982
4983 workspace.update(cx, |workspace, _cx| {
4984 workspace.set_random_database_id();
4985 });
4986
4987 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4988
4989 let panel = workspace.update_in(cx, |workspace, window, cx| {
4990 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4991 });
4992
4993 panel.update_in(cx, |panel, window, cx| {
4994 panel.open_external_thread_with_server(
4995 Rc::new(StubAgentServer::default_response()),
4996 window,
4997 cx,
4998 );
4999 });
5000
5001 cx.run_until_parked();
5002
5003 panel.read_with(cx, |panel, cx| {
5004 assert!(
5005 panel.active_agent_thread(cx).is_some(),
5006 "should have an active thread after connection"
5007 );
5008 });
5009
5010 // Serialize without ever sending a message, so no thread metadata exists.
5011 panel.update(cx, |panel, cx| panel.serialize(cx));
5012 cx.run_until_parked();
5013
5014 let async_cx = cx.update(|window, cx| window.to_async(cx));
5015 let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
5016 .await
5017 .expect("panel load should succeed");
5018 cx.run_until_parked();
5019
5020 loaded.read_with(cx, |panel, _cx| {
5021 assert!(
5022 panel.active_conversation_view().is_none(),
5023 "thread without metadata should not be restored"
5024 );
5025 });
5026 }
5027
5028 /// Extracts the text from a Text content block, panicking if it's not Text.
5029 fn expect_text_block(block: &acp::ContentBlock) -> &str {
5030 match block {
5031 acp::ContentBlock::Text(t) => t.text.as_str(),
5032 other => panic!("expected Text block, got {:?}", other),
5033 }
5034 }
5035
5036 /// Extracts the (text_content, uri) from a Resource content block, panicking
5037 /// if it's not a TextResourceContents resource.
5038 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
5039 match block {
5040 acp::ContentBlock::Resource(r) => match &r.resource {
5041 acp::EmbeddedResourceResource::TextResourceContents(t) => {
5042 (t.text.as_str(), t.uri.as_str())
5043 }
5044 other => panic!("expected TextResourceContents, got {:?}", other),
5045 },
5046 other => panic!("expected Resource block, got {:?}", other),
5047 }
5048 }
5049
5050 #[test]
5051 fn test_build_conflict_resolution_prompt_single_conflict() {
5052 let conflicts = vec![ConflictContent {
5053 file_path: "src/main.rs".to_string(),
5054 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
5055 .to_string(),
5056 ours_branch_name: "HEAD".to_string(),
5057 theirs_branch_name: "feature".to_string(),
5058 }];
5059
5060 let blocks = build_conflict_resolution_prompt(&conflicts);
5061 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
5062 assert_eq!(
5063 blocks.len(),
5064 4,
5065 "expected 2 text + 1 resource link + 1 resource block"
5066 );
5067
5068 let intro_text = expect_text_block(&blocks[0]);
5069 assert!(
5070 intro_text.contains("Please resolve the following merge conflict in"),
5071 "prompt should include single-conflict intro text"
5072 );
5073
5074 match &blocks[1] {
5075 acp::ContentBlock::ResourceLink(link) => {
5076 assert!(
5077 link.uri.contains("file://"),
5078 "resource link URI should use file scheme"
5079 );
5080 assert!(
5081 link.uri.contains("main.rs"),
5082 "resource link URI should reference file path"
5083 );
5084 }
5085 other => panic!("expected ResourceLink block, got {:?}", other),
5086 }
5087
5088 let body_text = expect_text_block(&blocks[2]);
5089 assert!(
5090 body_text.contains("`HEAD` (ours)"),
5091 "prompt should mention ours branch"
5092 );
5093 assert!(
5094 body_text.contains("`feature` (theirs)"),
5095 "prompt should mention theirs branch"
5096 );
5097 assert!(
5098 body_text.contains("editing the file directly"),
5099 "prompt should instruct the agent to edit the file"
5100 );
5101
5102 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
5103 assert!(
5104 resource_text.contains("<<<<<<< HEAD"),
5105 "resource should contain the conflict text"
5106 );
5107 assert!(
5108 resource_uri.contains("merge-conflict"),
5109 "resource URI should use the merge-conflict scheme"
5110 );
5111 assert!(
5112 resource_uri.contains("main.rs"),
5113 "resource URI should reference the file path"
5114 );
5115 }
5116
5117 #[test]
5118 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
5119 let conflicts = vec![
5120 ConflictContent {
5121 file_path: "src/lib.rs".to_string(),
5122 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
5123 .to_string(),
5124 ours_branch_name: "main".to_string(),
5125 theirs_branch_name: "dev".to_string(),
5126 },
5127 ConflictContent {
5128 file_path: "src/lib.rs".to_string(),
5129 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5130 .to_string(),
5131 ours_branch_name: "main".to_string(),
5132 theirs_branch_name: "dev".to_string(),
5133 },
5134 ];
5135
5136 let blocks = build_conflict_resolution_prompt(&conflicts);
5137 // 1 Text instruction + 2 Resource blocks
5138 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5139
5140 let text = expect_text_block(&blocks[0]);
5141 assert!(
5142 text.contains("all 2 merge conflicts"),
5143 "prompt should mention the total count"
5144 );
5145 assert!(
5146 text.contains("`main` (ours)"),
5147 "prompt should mention ours branch"
5148 );
5149 assert!(
5150 text.contains("`dev` (theirs)"),
5151 "prompt should mention theirs branch"
5152 );
5153 // Single file, so "file" not "files"
5154 assert!(
5155 text.contains("file directly"),
5156 "single file should use singular 'file'"
5157 );
5158
5159 let (resource_a, _) = expect_resource_block(&blocks[1]);
5160 let (resource_b, _) = expect_resource_block(&blocks[2]);
5161 assert!(
5162 resource_a.contains("fn a()"),
5163 "first resource should contain first conflict"
5164 );
5165 assert!(
5166 resource_b.contains("fn b()"),
5167 "second resource should contain second conflict"
5168 );
5169 }
5170
5171 #[test]
5172 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5173 let conflicts = vec![
5174 ConflictContent {
5175 file_path: "src/a.rs".to_string(),
5176 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5177 ours_branch_name: "main".to_string(),
5178 theirs_branch_name: "dev".to_string(),
5179 },
5180 ConflictContent {
5181 file_path: "src/b.rs".to_string(),
5182 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5183 ours_branch_name: "main".to_string(),
5184 theirs_branch_name: "dev".to_string(),
5185 },
5186 ];
5187
5188 let blocks = build_conflict_resolution_prompt(&conflicts);
5189 // 1 Text instruction + 2 Resource blocks
5190 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5191
5192 let text = expect_text_block(&blocks[0]);
5193 assert!(
5194 text.contains("files directly"),
5195 "multiple files should use plural 'files'"
5196 );
5197
5198 let (_, uri_a) = expect_resource_block(&blocks[1]);
5199 let (_, uri_b) = expect_resource_block(&blocks[2]);
5200 assert!(
5201 uri_a.contains("a.rs"),
5202 "first resource URI should reference a.rs"
5203 );
5204 assert!(
5205 uri_b.contains("b.rs"),
5206 "second resource URI should reference b.rs"
5207 );
5208 }
5209
5210 #[test]
5211 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5212 let file_paths = vec![
5213 "src/main.rs".to_string(),
5214 "src/lib.rs".to_string(),
5215 "tests/integration.rs".to_string(),
5216 ];
5217
5218 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5219 // 1 instruction Text block + (ResourceLink + newline Text) per file
5220 assert_eq!(
5221 blocks.len(),
5222 1 + (file_paths.len() * 2),
5223 "expected instruction text plus resource links and separators"
5224 );
5225
5226 let text = expect_text_block(&blocks[0]);
5227 assert!(
5228 text.contains("unresolved merge conflicts"),
5229 "prompt should describe the task"
5230 );
5231 assert!(
5232 text.contains("conflict markers"),
5233 "prompt should mention conflict markers"
5234 );
5235
5236 for (index, path) in file_paths.iter().enumerate() {
5237 let link_index = 1 + (index * 2);
5238 let newline_index = link_index + 1;
5239
5240 match &blocks[link_index] {
5241 acp::ContentBlock::ResourceLink(link) => {
5242 assert!(
5243 link.uri.contains("file://"),
5244 "resource link URI should use file scheme"
5245 );
5246 assert!(
5247 link.uri.contains(path),
5248 "resource link URI should reference file path: {path}"
5249 );
5250 }
5251 other => panic!(
5252 "expected ResourceLink block at index {}, got {:?}",
5253 link_index, other
5254 ),
5255 }
5256
5257 let separator = expect_text_block(&blocks[newline_index]);
5258 assert_eq!(
5259 separator, "\n",
5260 "expected newline separator after each file"
5261 );
5262 }
5263 }
5264
5265 #[test]
5266 fn test_build_conflict_resolution_prompt_empty_conflicts() {
5267 let blocks = build_conflict_resolution_prompt(&[]);
5268 assert!(
5269 blocks.is_empty(),
5270 "empty conflicts should produce no blocks, got {} blocks",
5271 blocks.len()
5272 );
5273 }
5274
5275 #[test]
5276 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5277 let blocks = build_conflicted_files_resolution_prompt(&[]);
5278 assert!(
5279 blocks.is_empty(),
5280 "empty paths should produce no blocks, got {} blocks",
5281 blocks.len()
5282 );
5283 }
5284
5285 #[test]
5286 fn test_conflict_resource_block_structure() {
5287 let conflict = ConflictContent {
5288 file_path: "src/utils.rs".to_string(),
5289 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5290 ours_branch_name: "HEAD".to_string(),
5291 theirs_branch_name: "branch".to_string(),
5292 };
5293
5294 let block = conflict_resource_block(&conflict);
5295 let (text, uri) = expect_resource_block(&block);
5296
5297 assert_eq!(
5298 text, conflict.conflict_text,
5299 "resource text should be the raw conflict"
5300 );
5301 assert!(
5302 uri.starts_with("zed:///agent/merge-conflict"),
5303 "URI should use the zed merge-conflict scheme, got: {uri}"
5304 );
5305 assert!(uri.contains("utils.rs"), "URI should encode the file path");
5306 }
5307
5308 fn open_generating_thread_with_loadable_connection(
5309 panel: &Entity<AgentPanel>,
5310 connection: &StubAgentConnection,
5311 cx: &mut VisualTestContext,
5312 ) -> acp::SessionId {
5313 open_thread_with_custom_connection(panel, connection.clone(), cx);
5314 let session_id = active_session_id(panel, cx);
5315 send_message(panel, cx);
5316 cx.update(|_, cx| {
5317 connection.send_update(
5318 session_id.clone(),
5319 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5320 cx,
5321 );
5322 });
5323 cx.run_until_parked();
5324 session_id
5325 }
5326
5327 fn open_idle_thread_with_non_loadable_connection(
5328 panel: &Entity<AgentPanel>,
5329 connection: &StubAgentConnection,
5330 cx: &mut VisualTestContext,
5331 ) -> acp::SessionId {
5332 open_thread_with_custom_connection(panel, connection.clone(), cx);
5333 let session_id = active_session_id(panel, cx);
5334
5335 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5336 acp::ContentChunk::new("done".into()),
5337 )]);
5338 send_message(panel, cx);
5339
5340 session_id
5341 }
5342
5343 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5344 init_test(cx);
5345 cx.update(|cx| {
5346 agent::ThreadStore::init_global(cx);
5347 language_model::LanguageModelRegistry::test(cx);
5348 });
5349
5350 let fs = FakeFs::new(cx.executor());
5351 let project = Project::test(fs.clone(), [], cx).await;
5352
5353 let multi_workspace =
5354 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5355
5356 let workspace = multi_workspace
5357 .read_with(cx, |mw, _cx| mw.workspace().clone())
5358 .unwrap();
5359
5360 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5361
5362 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5363 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5364 });
5365
5366 (panel, cx)
5367 }
5368
5369 #[gpui::test]
5370 async fn test_empty_draft_thread_not_retained_when_navigating_away(cx: &mut TestAppContext) {
5371 let (panel, mut cx) = setup_panel(cx).await;
5372
5373 let connection_a = StubAgentConnection::new();
5374 open_thread_with_connection(&panel, connection_a, &mut cx);
5375 let session_id_a = active_session_id(&panel, &cx);
5376
5377 panel.read_with(&cx, |panel, cx| {
5378 let thread = panel.active_agent_thread(cx).unwrap();
5379 assert!(
5380 thread.read(cx).entries().is_empty(),
5381 "newly opened draft thread should have no entries"
5382 );
5383 assert!(panel.background_threads.is_empty());
5384 });
5385
5386 let connection_b = StubAgentConnection::new();
5387 open_thread_with_connection(&panel, connection_b, &mut cx);
5388
5389 panel.read_with(&cx, |panel, _cx| {
5390 assert!(
5391 panel.background_threads.is_empty(),
5392 "empty draft thread should not be retained in background_threads"
5393 );
5394 assert!(
5395 !panel.background_threads.contains_key(&session_id_a),
5396 "empty draft thread should not be keyed in background_threads"
5397 );
5398 });
5399 }
5400
5401 #[gpui::test]
5402 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5403 let (panel, mut cx) = setup_panel(cx).await;
5404
5405 let connection_a = StubAgentConnection::new();
5406 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5407 send_message(&panel, &mut cx);
5408
5409 let session_id_a = active_session_id(&panel, &cx);
5410
5411 // Send a chunk to keep thread A generating (don't end the turn).
5412 cx.update(|_, cx| {
5413 connection_a.send_update(
5414 session_id_a.clone(),
5415 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5416 cx,
5417 );
5418 });
5419 cx.run_until_parked();
5420
5421 // Verify thread A is generating.
5422 panel.read_with(&cx, |panel, cx| {
5423 let thread = panel.active_agent_thread(cx).unwrap();
5424 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5425 assert!(panel.background_threads.is_empty());
5426 });
5427
5428 // Open a new thread B — thread A should be retained in background.
5429 let connection_b = StubAgentConnection::new();
5430 open_thread_with_connection(&panel, connection_b, &mut cx);
5431
5432 panel.read_with(&cx, |panel, _cx| {
5433 assert_eq!(
5434 panel.background_threads.len(),
5435 1,
5436 "Running thread A should be retained in background_views"
5437 );
5438 assert!(
5439 panel.background_threads.contains_key(&session_id_a),
5440 "Background view should be keyed by thread A's session ID"
5441 );
5442 });
5443 }
5444
5445 #[gpui::test]
5446 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5447 let (panel, mut cx) = setup_panel(cx).await;
5448
5449 let connection_a = StubAgentConnection::new();
5450 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5451 acp::ContentChunk::new("Response".into()),
5452 )]);
5453 open_thread_with_connection(&panel, connection_a, &mut cx);
5454 send_message(&panel, &mut cx);
5455
5456 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5457 panel.active_conversation_view().unwrap().downgrade()
5458 });
5459 let session_id_a = active_session_id(&panel, &cx);
5460
5461 // Thread A should be idle (auto-completed via set_next_prompt_updates).
5462 panel.read_with(&cx, |panel, cx| {
5463 let thread = panel.active_agent_thread(cx).unwrap();
5464 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5465 });
5466
5467 // Open a new thread B — thread A should be retained because it is not loadable.
5468 let connection_b = StubAgentConnection::new();
5469 open_thread_with_connection(&panel, connection_b, &mut cx);
5470
5471 panel.read_with(&cx, |panel, _cx| {
5472 assert_eq!(
5473 panel.background_threads.len(),
5474 1,
5475 "Idle non-loadable thread A should be retained in background_views"
5476 );
5477 assert!(
5478 panel.background_threads.contains_key(&session_id_a),
5479 "Background view should be keyed by thread A's session ID"
5480 );
5481 });
5482
5483 assert!(
5484 weak_view_a.upgrade().is_some(),
5485 "Idle non-loadable ConnectionView should still be retained"
5486 );
5487 }
5488
5489 #[gpui::test]
5490 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5491 let (panel, mut cx) = setup_panel(cx).await;
5492
5493 let connection_a = StubAgentConnection::new();
5494 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5495 send_message(&panel, &mut cx);
5496
5497 let session_id_a = active_session_id(&panel, &cx);
5498
5499 // Keep thread A generating.
5500 cx.update(|_, cx| {
5501 connection_a.send_update(
5502 session_id_a.clone(),
5503 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5504 cx,
5505 );
5506 });
5507 cx.run_until_parked();
5508
5509 // Open thread B — thread A goes to background.
5510 let connection_b = StubAgentConnection::new();
5511 open_thread_with_connection(&panel, connection_b, &mut cx);
5512 send_message(&panel, &mut cx);
5513
5514 let session_id_b = active_session_id(&panel, &cx);
5515
5516 panel.read_with(&cx, |panel, _cx| {
5517 assert_eq!(panel.background_threads.len(), 1);
5518 assert!(panel.background_threads.contains_key(&session_id_a));
5519 });
5520
5521 // Load thread A back via load_agent_thread — should promote from background.
5522 panel.update_in(&mut cx, |panel, window, cx| {
5523 panel.load_agent_thread(
5524 panel.selected_agent().expect("selected agent must be set"),
5525 session_id_a.clone(),
5526 None,
5527 None,
5528 true,
5529 window,
5530 cx,
5531 );
5532 });
5533
5534 // Thread A should now be the active view, promoted from background.
5535 let active_session = active_session_id(&panel, &cx);
5536 assert_eq!(
5537 active_session, session_id_a,
5538 "Thread A should be the active thread after promotion"
5539 );
5540
5541 panel.read_with(&cx, |panel, _cx| {
5542 assert!(
5543 !panel.background_threads.contains_key(&session_id_a),
5544 "Promoted thread A should no longer be in background_views"
5545 );
5546 assert!(
5547 panel.background_threads.contains_key(&session_id_b),
5548 "Thread B (idle, non-loadable) should remain retained in background_views"
5549 );
5550 });
5551 }
5552
5553 #[gpui::test]
5554 async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
5555 cx: &mut TestAppContext,
5556 ) {
5557 let (panel, mut cx) = setup_panel(cx).await;
5558 let connection = StubAgentConnection::new()
5559 .with_supports_load_session(true)
5560 .with_agent_id("loadable-stub".into())
5561 .with_telemetry_id("loadable-stub".into());
5562 let mut session_ids = Vec::new();
5563
5564 for _ in 0..7 {
5565 session_ids.push(open_generating_thread_with_loadable_connection(
5566 &panel,
5567 &connection,
5568 &mut cx,
5569 ));
5570 }
5571
5572 let base_time = Instant::now();
5573
5574 for session_id in session_ids.iter().take(6) {
5575 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5576 }
5577 cx.run_until_parked();
5578
5579 panel.update(&mut cx, |panel, cx| {
5580 for (index, session_id) in session_ids.iter().take(6).enumerate() {
5581 let conversation_view = panel
5582 .background_threads
5583 .get(session_id)
5584 .expect("background thread should exist")
5585 .clone();
5586 conversation_view.update(cx, |view, cx| {
5587 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5588 });
5589 }
5590 panel.cleanup_background_threads(cx);
5591 });
5592
5593 panel.read_with(&cx, |panel, _cx| {
5594 assert_eq!(
5595 panel.background_threads.len(),
5596 5,
5597 "cleanup should keep at most five idle loadable background threads"
5598 );
5599 assert!(
5600 !panel.background_threads.contains_key(&session_ids[0]),
5601 "oldest idle loadable background thread should be removed"
5602 );
5603 for session_id in &session_ids[1..6] {
5604 assert!(
5605 panel.background_threads.contains_key(session_id),
5606 "more recent idle loadable background threads should be retained"
5607 );
5608 }
5609 assert!(
5610 !panel.background_threads.contains_key(&session_ids[6]),
5611 "the active thread should not also be stored as a background thread"
5612 );
5613 });
5614 }
5615
5616 #[gpui::test]
5617 async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
5618 cx: &mut TestAppContext,
5619 ) {
5620 let (panel, mut cx) = setup_panel(cx).await;
5621
5622 let non_loadable_connection = StubAgentConnection::new();
5623 let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
5624 &panel,
5625 &non_loadable_connection,
5626 &mut cx,
5627 );
5628
5629 let loadable_connection = StubAgentConnection::new()
5630 .with_supports_load_session(true)
5631 .with_agent_id("loadable-stub".into())
5632 .with_telemetry_id("loadable-stub".into());
5633 let mut loadable_session_ids = Vec::new();
5634
5635 for _ in 0..7 {
5636 loadable_session_ids.push(open_generating_thread_with_loadable_connection(
5637 &panel,
5638 &loadable_connection,
5639 &mut cx,
5640 ));
5641 }
5642
5643 let base_time = Instant::now();
5644
5645 for session_id in loadable_session_ids.iter().take(6) {
5646 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5647 }
5648 cx.run_until_parked();
5649
5650 panel.update(&mut cx, |panel, cx| {
5651 for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
5652 let conversation_view = panel
5653 .background_threads
5654 .get(session_id)
5655 .expect("background thread should exist")
5656 .clone();
5657 conversation_view.update(cx, |view, cx| {
5658 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5659 });
5660 }
5661 panel.cleanup_background_threads(cx);
5662 });
5663
5664 panel.read_with(&cx, |panel, _cx| {
5665 assert_eq!(
5666 panel.background_threads.len(),
5667 6,
5668 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
5669 );
5670 assert!(
5671 panel
5672 .background_threads
5673 .contains_key(&non_loadable_session_id),
5674 "idle non-loadable background threads should not be cleanup candidates"
5675 );
5676 assert!(
5677 !panel
5678 .background_threads
5679 .contains_key(&loadable_session_ids[0]),
5680 "oldest idle loadable background thread should still be removed"
5681 );
5682 for session_id in &loadable_session_ids[1..6] {
5683 assert!(
5684 panel.background_threads.contains_key(session_id),
5685 "more recent idle loadable background threads should be retained"
5686 );
5687 }
5688 assert!(
5689 !panel
5690 .background_threads
5691 .contains_key(&loadable_session_ids[6]),
5692 "the active loadable thread should not also be stored as a background thread"
5693 );
5694 });
5695 }
5696
5697 #[gpui::test]
5698 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5699 init_test(cx);
5700 cx.update(|cx| {
5701 agent::ThreadStore::init_global(cx);
5702 language_model::LanguageModelRegistry::test(cx);
5703 });
5704
5705 let fs = FakeFs::new(cx.executor());
5706 fs.insert_tree(
5707 "/project",
5708 json!({
5709 ".git": {},
5710 "src": {
5711 "main.rs": "fn main() {}"
5712 }
5713 }),
5714 )
5715 .await;
5716 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5717
5718 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5719
5720 let multi_workspace =
5721 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5722
5723 let workspace = multi_workspace
5724 .read_with(cx, |multi_workspace, _cx| {
5725 multi_workspace.workspace().clone()
5726 })
5727 .unwrap();
5728
5729 workspace.update(cx, |workspace, _cx| {
5730 workspace.set_random_database_id();
5731 });
5732
5733 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5734
5735 // Wait for the project to discover the git repository.
5736 cx.run_until_parked();
5737
5738 let panel = workspace.update_in(cx, |workspace, window, cx| {
5739 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5740 workspace.add_panel(panel.clone(), window, cx);
5741 panel
5742 });
5743
5744 cx.run_until_parked();
5745
5746 // Default thread target should be LocalProject.
5747 panel.read_with(cx, |panel, _cx| {
5748 assert_eq!(
5749 *panel.start_thread_in(),
5750 StartThreadIn::LocalProject,
5751 "default thread target should be LocalProject"
5752 );
5753 });
5754
5755 // Start a new thread with the default LocalProject target.
5756 // Use StubAgentServer so the thread connects immediately in tests.
5757 panel.update_in(cx, |panel, window, cx| {
5758 panel.open_external_thread_with_server(
5759 Rc::new(StubAgentServer::default_response()),
5760 window,
5761 cx,
5762 );
5763 });
5764
5765 cx.run_until_parked();
5766
5767 // MultiWorkspace should still have exactly one workspace (no worktree created).
5768 multi_workspace
5769 .read_with(cx, |multi_workspace, _cx| {
5770 assert_eq!(
5771 multi_workspace.workspaces().count(),
5772 1,
5773 "LocalProject should not create a new workspace"
5774 );
5775 })
5776 .unwrap();
5777
5778 // The thread should be active in the panel.
5779 panel.read_with(cx, |panel, cx| {
5780 assert!(
5781 panel.active_agent_thread(cx).is_some(),
5782 "a thread should be running in the current workspace"
5783 );
5784 });
5785
5786 // The thread target should still be LocalProject (unchanged).
5787 panel.read_with(cx, |panel, _cx| {
5788 assert_eq!(
5789 *panel.start_thread_in(),
5790 StartThreadIn::LocalProject,
5791 "thread target should remain LocalProject"
5792 );
5793 });
5794
5795 // No worktree creation status should be set.
5796 panel.read_with(cx, |panel, _cx| {
5797 assert!(
5798 panel.worktree_creation_status.is_none(),
5799 "no worktree creation should have occurred"
5800 );
5801 });
5802 }
5803
5804 #[gpui::test]
5805 async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target(
5806 cx: &mut TestAppContext,
5807 ) {
5808 use git::repository::Worktree as GitWorktree;
5809
5810 init_test(cx);
5811 cx.update(|cx| {
5812 agent::ThreadStore::init_global(cx);
5813 language_model::LanguageModelRegistry::test(cx);
5814 });
5815
5816 let fs = FakeFs::new(cx.executor());
5817 fs.insert_tree(
5818 "/project",
5819 json!({
5820 ".git": {},
5821 "src": {
5822 "main.rs": "fn main() {}"
5823 }
5824 }),
5825 )
5826 .await;
5827 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5828 fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]);
5829
5830 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5831
5832 let multi_workspace =
5833 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5834
5835 let workspace = multi_workspace
5836 .read_with(cx, |multi_workspace, _cx| {
5837 multi_workspace.workspace().clone()
5838 })
5839 .unwrap();
5840
5841 workspace.update(cx, |workspace, _cx| {
5842 workspace.set_random_database_id();
5843 });
5844
5845 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5846
5847 cx.run_until_parked();
5848
5849 let panel = workspace.update_in(cx, |workspace, window, cx| {
5850 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5851 workspace.add_panel(panel.clone(), window, cx);
5852 panel
5853 });
5854
5855 cx.run_until_parked();
5856
5857 panel.update_in(cx, |panel, window, cx| {
5858 panel.set_start_thread_in(
5859 &StartThreadIn::NewWorktree {
5860 worktree_name: Some("feature worktree".to_string()),
5861 branch_target: NewWorktreeBranchTarget::CurrentBranch,
5862 },
5863 window,
5864 cx,
5865 );
5866 });
5867
5868 fs.add_linked_worktree_for_repo(
5869 Path::new("/project/.git"),
5870 true,
5871 GitWorktree {
5872 path: PathBuf::from("/linked-feature-worktree"),
5873 ref_name: Some("refs/heads/feature-worktree".into()),
5874 sha: "abcdef1".into(),
5875 is_main: false,
5876 },
5877 )
5878 .await;
5879
5880 project
5881 .update(cx, |project, cx| project.git_scans_complete(cx))
5882 .await;
5883 cx.run_until_parked();
5884
5885 panel.read_with(cx, |panel, _cx| {
5886 assert_eq!(
5887 *panel.start_thread_in(),
5888 StartThreadIn::NewWorktree {
5889 worktree_name: Some("feature worktree".to_string()),
5890 branch_target: NewWorktreeBranchTarget::CurrentBranch,
5891 },
5892 "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target",
5893 );
5894 });
5895 }
5896
5897 #[gpui::test]
5898 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5899 init_test(cx);
5900 cx.update(|cx| {
5901 agent::ThreadStore::init_global(cx);
5902 language_model::LanguageModelRegistry::test(cx);
5903 });
5904
5905 let fs = FakeFs::new(cx.executor());
5906 fs.insert_tree(
5907 "/project",
5908 json!({
5909 ".git": {},
5910 "src": {
5911 "main.rs": "fn main() {}"
5912 }
5913 }),
5914 )
5915 .await;
5916 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5917
5918 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5919
5920 let multi_workspace =
5921 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5922
5923 let workspace = multi_workspace
5924 .read_with(cx, |multi_workspace, _cx| {
5925 multi_workspace.workspace().clone()
5926 })
5927 .unwrap();
5928
5929 workspace.update(cx, |workspace, _cx| {
5930 workspace.set_random_database_id();
5931 });
5932
5933 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5934
5935 // Wait for the project to discover the git repository.
5936 cx.run_until_parked();
5937
5938 let panel = workspace.update_in(cx, |workspace, window, cx| {
5939 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5940 workspace.add_panel(panel.clone(), window, cx);
5941 panel
5942 });
5943
5944 cx.run_until_parked();
5945
5946 // Default should be LocalProject.
5947 panel.read_with(cx, |panel, _cx| {
5948 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5949 });
5950
5951 // Change thread target to NewWorktree.
5952 panel.update_in(cx, |panel, window, cx| {
5953 panel.set_start_thread_in(
5954 &StartThreadIn::NewWorktree {
5955 worktree_name: None,
5956 branch_target: NewWorktreeBranchTarget::default(),
5957 },
5958 window,
5959 cx,
5960 );
5961 });
5962
5963 panel.read_with(cx, |panel, _cx| {
5964 assert_eq!(
5965 *panel.start_thread_in(),
5966 StartThreadIn::NewWorktree {
5967 worktree_name: None,
5968 branch_target: NewWorktreeBranchTarget::default(),
5969 },
5970 "thread target should be NewWorktree after set_thread_target"
5971 );
5972 });
5973
5974 // Let serialization complete.
5975 cx.run_until_parked();
5976
5977 // Load a fresh panel from the serialized data.
5978 let async_cx = cx.update(|window, cx| window.to_async(cx));
5979 let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
5980 .await
5981 .expect("panel load should succeed");
5982 cx.run_until_parked();
5983
5984 loaded_panel.read_with(cx, |panel, _cx| {
5985 assert_eq!(
5986 *panel.start_thread_in(),
5987 StartThreadIn::NewWorktree {
5988 worktree_name: None,
5989 branch_target: NewWorktreeBranchTarget::default(),
5990 },
5991 "thread target should survive serialization round-trip"
5992 );
5993 });
5994 }
5995
5996 #[gpui::test]
5997 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
5998 init_test(cx);
5999
6000 let fs = FakeFs::new(cx.executor());
6001 cx.update(|cx| {
6002 agent::ThreadStore::init_global(cx);
6003 language_model::LanguageModelRegistry::test(cx);
6004 <dyn fs::Fs>::set_global(fs.clone(), cx);
6005 });
6006
6007 fs.insert_tree(
6008 "/project",
6009 json!({
6010 ".git": {},
6011 "src": {
6012 "main.rs": "fn main() {}"
6013 }
6014 }),
6015 )
6016 .await;
6017
6018 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6019
6020 let multi_workspace =
6021 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6022
6023 let workspace = multi_workspace
6024 .read_with(cx, |multi_workspace, _cx| {
6025 multi_workspace.workspace().clone()
6026 })
6027 .unwrap();
6028
6029 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6030
6031 let panel = workspace.update_in(cx, |workspace, window, cx| {
6032 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6033 workspace.add_panel(panel.clone(), window, cx);
6034 panel
6035 });
6036
6037 cx.run_until_parked();
6038
6039 // Simulate worktree creation in progress and reset to Uninitialized
6040 panel.update_in(cx, |panel, window, cx| {
6041 panel.worktree_creation_status =
6042 Some((EntityId::from(0u64), WorktreeCreationStatus::Creating));
6043 panel.active_view = ActiveView::Uninitialized;
6044 Panel::set_active(panel, true, window, cx);
6045 assert!(
6046 matches!(panel.active_view, ActiveView::Uninitialized),
6047 "set_active should not create a thread while worktree is being created"
6048 );
6049 });
6050
6051 // Clear the creation status and use open_external_thread_with_server
6052 // (which bypasses new_agent_thread) to verify the panel can transition
6053 // out of Uninitialized. We can't call set_active directly because
6054 // new_agent_thread requires full agent server infrastructure.
6055 panel.update_in(cx, |panel, window, cx| {
6056 panel.worktree_creation_status = None;
6057 panel.active_view = ActiveView::Uninitialized;
6058 panel.open_external_thread_with_server(
6059 Rc::new(StubAgentServer::default_response()),
6060 window,
6061 cx,
6062 );
6063 });
6064
6065 cx.run_until_parked();
6066
6067 panel.read_with(cx, |panel, _cx| {
6068 assert!(
6069 !matches!(panel.active_view, ActiveView::Uninitialized),
6070 "panel should transition out of Uninitialized once worktree creation is cleared"
6071 );
6072 });
6073 }
6074
6075 #[test]
6076 fn test_deserialize_agent_variants() {
6077 // PascalCase (legacy AgentType format, persisted in panel state)
6078 assert_eq!(
6079 serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
6080 Agent::NativeAgent,
6081 );
6082 assert_eq!(
6083 serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
6084 Agent::Custom {
6085 id: "my-agent".into(),
6086 },
6087 );
6088
6089 // Legacy TextThread variant deserializes to NativeAgent
6090 assert_eq!(
6091 serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
6092 Agent::NativeAgent,
6093 );
6094
6095 // snake_case (canonical format)
6096 assert_eq!(
6097 serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
6098 Agent::NativeAgent,
6099 );
6100 assert_eq!(
6101 serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
6102 Agent::Custom {
6103 id: "my-agent".into(),
6104 },
6105 );
6106
6107 // Serialization uses snake_case
6108 assert_eq!(
6109 serde_json::to_string(&Agent::NativeAgent).unwrap(),
6110 r#""native_agent""#,
6111 );
6112 assert_eq!(
6113 serde_json::to_string(&Agent::Custom {
6114 id: "my-agent".into()
6115 })
6116 .unwrap(),
6117 r#"{"custom":{"name":"my-agent"}}"#,
6118 );
6119 }
6120
6121 #[test]
6122 fn test_resolve_worktree_branch_target() {
6123 let existing_branches = HashSet::from_iter([
6124 "main".to_string(),
6125 "feature".to_string(),
6126 "origin/main".to_string(),
6127 ]);
6128
6129 let resolved = AgentPanel::resolve_worktree_branch_target(
6130 &NewWorktreeBranchTarget::CreateBranch {
6131 name: "new-branch".to_string(),
6132 from_ref: Some("main".to_string()),
6133 },
6134 &existing_branches,
6135 &HashSet::from_iter(["main".to_string()]),
6136 )
6137 .unwrap();
6138 assert_eq!(
6139 resolved,
6140 ("new-branch".to_string(), false, Some("main".to_string()))
6141 );
6142
6143 let resolved = AgentPanel::resolve_worktree_branch_target(
6144 &NewWorktreeBranchTarget::ExistingBranch {
6145 name: "feature".to_string(),
6146 },
6147 &existing_branches,
6148 &HashSet::default(),
6149 )
6150 .unwrap();
6151 assert_eq!(resolved, ("feature".to_string(), true, None));
6152
6153 let resolved = AgentPanel::resolve_worktree_branch_target(
6154 &NewWorktreeBranchTarget::ExistingBranch {
6155 name: "main".to_string(),
6156 },
6157 &existing_branches,
6158 &HashSet::from_iter(["main".to_string()]),
6159 )
6160 .unwrap();
6161 assert_eq!(resolved.1, false);
6162 assert_eq!(resolved.2, Some("main".to_string()));
6163 assert_ne!(resolved.0, "main");
6164 assert!(existing_branches.contains("main"));
6165 assert!(!existing_branches.contains(&resolved.0));
6166 }
6167
6168 #[gpui::test]
6169 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6170 init_test(cx);
6171
6172 let app_state = cx.update(|cx| {
6173 agent::ThreadStore::init_global(cx);
6174 language_model::LanguageModelRegistry::test(cx);
6175
6176 let app_state = workspace::AppState::test(cx);
6177 workspace::init(app_state.clone(), cx);
6178 app_state
6179 });
6180
6181 let fs = app_state.fs.as_fake();
6182 fs.insert_tree(
6183 "/project",
6184 json!({
6185 ".git": {},
6186 "src": {
6187 "main.rs": "fn main() {}"
6188 }
6189 }),
6190 )
6191 .await;
6192 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6193
6194 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6195
6196 let multi_workspace =
6197 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6198 multi_workspace
6199 .update(cx, |multi_workspace, _, cx| {
6200 multi_workspace.open_sidebar(cx);
6201 })
6202 .unwrap();
6203
6204 let workspace = multi_workspace
6205 .read_with(cx, |multi_workspace, _cx| {
6206 multi_workspace.workspace().clone()
6207 })
6208 .unwrap();
6209
6210 workspace.update(cx, |workspace, _cx| {
6211 workspace.set_random_database_id();
6212 });
6213
6214 // Register a callback so new workspaces also get an AgentPanel.
6215 cx.update(|cx| {
6216 cx.observe_new(
6217 |workspace: &mut Workspace,
6218 window: Option<&mut Window>,
6219 cx: &mut Context<Workspace>| {
6220 if let Some(window) = window {
6221 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6222 workspace.add_panel(panel, window, cx);
6223 }
6224 },
6225 )
6226 .detach();
6227 });
6228
6229 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6230
6231 // Wait for the project to discover the git repository.
6232 cx.run_until_parked();
6233
6234 let panel = workspace.update_in(cx, |workspace, window, cx| {
6235 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6236 workspace.add_panel(panel.clone(), window, cx);
6237 panel
6238 });
6239
6240 cx.run_until_parked();
6241
6242 // Open a thread (needed so there's an active thread view).
6243 panel.update_in(cx, |panel, window, cx| {
6244 panel.open_external_thread_with_server(
6245 Rc::new(StubAgentServer::default_response()),
6246 window,
6247 cx,
6248 );
6249 });
6250
6251 cx.run_until_parked();
6252
6253 // Set the selected agent to Codex (a custom agent) and start_thread_in
6254 // to NewWorktree. We do this AFTER opening the thread because
6255 // open_external_thread_with_server overrides selected_agent.
6256 panel.update_in(cx, |panel, window, cx| {
6257 panel.selected_agent = Agent::Custom {
6258 id: CODEX_ID.into(),
6259 };
6260 panel.set_start_thread_in(
6261 &StartThreadIn::NewWorktree {
6262 worktree_name: None,
6263 branch_target: NewWorktreeBranchTarget::default(),
6264 },
6265 window,
6266 cx,
6267 );
6268 });
6269
6270 // Verify the panel has the Codex agent selected.
6271 panel.read_with(cx, |panel, _cx| {
6272 assert_eq!(
6273 panel.selected_agent,
6274 Agent::Custom {
6275 id: CODEX_ID.into()
6276 },
6277 );
6278 });
6279
6280 // Directly call handle_worktree_creation_requested, which is what
6281 // handle_first_send_requested does when start_thread_in == NewWorktree.
6282 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6283 "Hello from test",
6284 ))];
6285 panel.update_in(cx, |panel, window, cx| {
6286 panel.handle_worktree_requested(
6287 content,
6288 WorktreeCreationArgs::New {
6289 worktree_name: None,
6290 branch_target: NewWorktreeBranchTarget::default(),
6291 },
6292 window,
6293 cx,
6294 );
6295 });
6296
6297 // Let the async worktree creation + workspace setup complete.
6298 cx.run_until_parked();
6299
6300 panel.read_with(cx, |panel, _cx| {
6301 assert_eq!(
6302 panel.start_thread_in(),
6303 &StartThreadIn::LocalProject,
6304 "the original panel should reset start_thread_in back to the local project after creating a worktree workspace",
6305 );
6306 });
6307
6308 // Find the new workspace's AgentPanel and verify it used the Codex agent.
6309 let found_codex = multi_workspace
6310 .read_with(cx, |multi_workspace, cx| {
6311 // There should be more than one workspace now (the original + the new worktree).
6312 assert!(
6313 multi_workspace.workspaces().count() > 1,
6314 "expected a new workspace to have been created, found {}",
6315 multi_workspace.workspaces().count(),
6316 );
6317
6318 // Check the newest workspace's panel for the correct agent.
6319 let new_workspace = multi_workspace
6320 .workspaces()
6321 .find(|ws| ws.entity_id() != workspace.entity_id())
6322 .expect("should find the new workspace");
6323 let new_panel = new_workspace
6324 .read(cx)
6325 .panel::<AgentPanel>(cx)
6326 .expect("new workspace should have an AgentPanel");
6327
6328 new_panel.read(cx).selected_agent.clone()
6329 })
6330 .unwrap();
6331
6332 assert_eq!(
6333 found_codex,
6334 Agent::Custom {
6335 id: CODEX_ID.into()
6336 },
6337 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6338 );
6339 }
6340
6341 #[gpui::test]
6342 async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
6343 use crate::thread_metadata_store::ThreadMetadataStore;
6344
6345 init_test(cx);
6346 cx.update(|cx| {
6347 agent::ThreadStore::init_global(cx);
6348 language_model::LanguageModelRegistry::test(cx);
6349 });
6350
6351 // Set up a project with one worktree.
6352 let fs = FakeFs::new(cx.executor());
6353 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
6354 .await;
6355 let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
6356
6357 let multi_workspace =
6358 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6359 let workspace = multi_workspace
6360 .read_with(cx, |mw, _cx| mw.workspace().clone())
6361 .unwrap();
6362 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
6363
6364 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
6365 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6366 });
6367
6368 // Open thread A and send a message. With empty next_prompt_updates it
6369 // stays generating, so opening B will move A to background_threads.
6370 let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
6371 open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
6372 send_message(&panel, &mut cx);
6373 let session_id_a = active_session_id(&panel, &cx);
6374
6375 // Open thread C — thread A (generating) moves to background.
6376 // Thread C completes immediately (idle), then opening B moves C to background too.
6377 let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
6378 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6379 acp::ContentChunk::new("done".into()),
6380 )]);
6381 open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
6382 send_message(&panel, &mut cx);
6383 let session_id_c = active_session_id(&panel, &cx);
6384
6385 // Open thread B — thread C (idle, non-loadable) is retained in background.
6386 let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
6387 open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
6388 send_message(&panel, &mut cx);
6389 let session_id_b = active_session_id(&panel, &cx);
6390
6391 let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
6392
6393 panel.read_with(&cx, |panel, _cx| {
6394 assert!(
6395 panel.background_threads.contains_key(&session_id_a),
6396 "Thread A should be in background_threads"
6397 );
6398 assert!(
6399 panel.background_threads.contains_key(&session_id_c),
6400 "Thread C should be in background_threads"
6401 );
6402 });
6403
6404 // Verify initial work_dirs for thread B contain only /project_a.
6405 let initial_b_paths = panel.read_with(&cx, |panel, cx| {
6406 let thread = panel.active_agent_thread(cx).unwrap();
6407 thread.read(cx).work_dirs().cloned().unwrap()
6408 });
6409 assert_eq!(
6410 initial_b_paths.ordered_paths().collect::<Vec<_>>(),
6411 vec![&PathBuf::from("/project_a")],
6412 "Thread B should initially have only /project_a"
6413 );
6414
6415 // Now add a second worktree to the project.
6416 fs.insert_tree("/project_b", json!({ "other.txt": "" }))
6417 .await;
6418 let (new_tree, _) = project
6419 .update(&mut cx, |project, cx| {
6420 project.find_or_create_worktree("/project_b", true, cx)
6421 })
6422 .await
6423 .unwrap();
6424 cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
6425 .await;
6426 cx.run_until_parked();
6427
6428 // Verify thread B's (active) work_dirs now include both worktrees.
6429 let updated_b_paths = panel.read_with(&cx, |panel, cx| {
6430 let thread = panel.active_agent_thread(cx).unwrap();
6431 thread.read(cx).work_dirs().cloned().unwrap()
6432 });
6433 let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
6434 b_paths_sorted.sort();
6435 assert_eq!(
6436 b_paths_sorted,
6437 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6438 "Thread B work_dirs should include both worktrees after adding /project_b"
6439 );
6440
6441 // Verify thread A's (background) work_dirs are also updated.
6442 let updated_a_paths = panel.read_with(&cx, |panel, cx| {
6443 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6444 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6445 root_thread
6446 .read(cx)
6447 .thread
6448 .read(cx)
6449 .work_dirs()
6450 .cloned()
6451 .unwrap()
6452 });
6453 let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
6454 a_paths_sorted.sort();
6455 assert_eq!(
6456 a_paths_sorted,
6457 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6458 "Thread A work_dirs should include both worktrees after adding /project_b"
6459 );
6460
6461 // Verify thread idle C was also updated.
6462 let updated_c_paths = panel.read_with(&cx, |panel, cx| {
6463 let bg_view = panel.background_threads.get(&session_id_c).unwrap();
6464 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6465 root_thread
6466 .read(cx)
6467 .thread
6468 .read(cx)
6469 .work_dirs()
6470 .cloned()
6471 .unwrap()
6472 });
6473 let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
6474 c_paths_sorted.sort();
6475 assert_eq!(
6476 c_paths_sorted,
6477 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6478 "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
6479 );
6480
6481 // Verify the metadata store reflects the new paths for running threads only.
6482 cx.run_until_parked();
6483 for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
6484 let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
6485 let metadata = store
6486 .entry(session_id)
6487 .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
6488 metadata.folder_paths.clone()
6489 });
6490 let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
6491 sorted.sort();
6492 assert_eq!(
6493 sorted,
6494 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6495 "{label} thread metadata folder_paths should include both worktrees"
6496 );
6497 }
6498
6499 // Now remove a worktree and verify work_dirs shrink.
6500 let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
6501 project.update(&mut cx, |project, cx| {
6502 project.remove_worktree(worktree_b_id, cx);
6503 });
6504 cx.run_until_parked();
6505
6506 let after_remove_b = panel.read_with(&cx, |panel, cx| {
6507 let thread = panel.active_agent_thread(cx).unwrap();
6508 thread.read(cx).work_dirs().cloned().unwrap()
6509 });
6510 assert_eq!(
6511 after_remove_b.ordered_paths().collect::<Vec<_>>(),
6512 vec![&PathBuf::from("/project_a")],
6513 "Thread B work_dirs should revert to only /project_a after removing /project_b"
6514 );
6515
6516 let after_remove_a = panel.read_with(&cx, |panel, cx| {
6517 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6518 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6519 root_thread
6520 .read(cx)
6521 .thread
6522 .read(cx)
6523 .work_dirs()
6524 .cloned()
6525 .unwrap()
6526 });
6527 assert_eq!(
6528 after_remove_a.ordered_paths().collect::<Vec<_>>(),
6529 vec![&PathBuf::from("/project_a")],
6530 "Thread A work_dirs should revert to only /project_a after removing /project_b"
6531 );
6532 }
6533
6534 #[gpui::test]
6535 async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
6536 init_test(cx);
6537 cx.update(|cx| {
6538 agent::ThreadStore::init_global(cx);
6539 language_model::LanguageModelRegistry::test(cx);
6540 // Use an isolated DB so parallel tests can't overwrite our global key.
6541 cx.set_global(db::AppDatabase::test_new());
6542 });
6543
6544 let custom_agent = Agent::Custom {
6545 id: "my-preferred-agent".into(),
6546 };
6547
6548 // Write a known agent to the global KVP to simulate a user who has
6549 // previously used this agent in another workspace.
6550 let kvp = cx.update(|cx| KeyValueStore::global(cx));
6551 write_global_last_used_agent(kvp, custom_agent.clone()).await;
6552
6553 let fs = FakeFs::new(cx.executor());
6554 let project = Project::test(fs.clone(), [], cx).await;
6555
6556 let multi_workspace =
6557 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6558
6559 let workspace = multi_workspace
6560 .read_with(cx, |multi_workspace, _cx| {
6561 multi_workspace.workspace().clone()
6562 })
6563 .unwrap();
6564
6565 workspace.update(cx, |workspace, _cx| {
6566 workspace.set_random_database_id();
6567 });
6568
6569 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6570
6571 // Load the panel via `load()`, which reads the global fallback
6572 // asynchronously when no per-workspace state exists.
6573 let async_cx = cx.update(|window, cx| window.to_async(cx));
6574 let panel = AgentPanel::load(workspace.downgrade(), async_cx)
6575 .await
6576 .expect("panel load should succeed");
6577 cx.run_until_parked();
6578
6579 panel.read_with(cx, |panel, _cx| {
6580 assert_eq!(
6581 panel.selected_agent, custom_agent,
6582 "new workspace should inherit the global last-used agent"
6583 );
6584 });
6585 }
6586
6587 #[gpui::test]
6588 async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
6589 init_test(cx);
6590 cx.update(|cx| {
6591 agent::ThreadStore::init_global(cx);
6592 language_model::LanguageModelRegistry::test(cx);
6593 });
6594
6595 let fs = FakeFs::new(cx.executor());
6596 let project_a = Project::test(fs.clone(), [], cx).await;
6597 let project_b = Project::test(fs, [], cx).await;
6598
6599 let multi_workspace =
6600 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6601
6602 let workspace_a = multi_workspace
6603 .read_with(cx, |multi_workspace, _cx| {
6604 multi_workspace.workspace().clone()
6605 })
6606 .unwrap();
6607
6608 let workspace_b = multi_workspace
6609 .update(cx, |multi_workspace, window, cx| {
6610 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
6611 })
6612 .unwrap();
6613
6614 workspace_a.update(cx, |workspace, _cx| {
6615 workspace.set_random_database_id();
6616 });
6617 workspace_b.update(cx, |workspace, _cx| {
6618 workspace.set_random_database_id();
6619 });
6620
6621 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6622
6623 let agent_a = Agent::Custom {
6624 id: "agent-alpha".into(),
6625 };
6626 let agent_b = Agent::Custom {
6627 id: "agent-beta".into(),
6628 };
6629
6630 // Set up workspace A with agent_a
6631 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
6632 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6633 });
6634 panel_a.update(cx, |panel, _cx| {
6635 panel.selected_agent = agent_a.clone();
6636 });
6637
6638 // Set up workspace B with agent_b
6639 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
6640 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6641 });
6642 panel_b.update(cx, |panel, _cx| {
6643 panel.selected_agent = agent_b.clone();
6644 });
6645
6646 // Serialize both panels
6647 panel_a.update(cx, |panel, cx| panel.serialize(cx));
6648 panel_b.update(cx, |panel, cx| panel.serialize(cx));
6649 cx.run_until_parked();
6650
6651 // Load fresh panels from serialized state and verify independence
6652 let async_cx = cx.update(|window, cx| window.to_async(cx));
6653 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
6654 .await
6655 .expect("panel A load should succeed");
6656 cx.run_until_parked();
6657
6658 let async_cx = cx.update(|window, cx| window.to_async(cx));
6659 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
6660 .await
6661 .expect("panel B load should succeed");
6662 cx.run_until_parked();
6663
6664 loaded_a.read_with(cx, |panel, _cx| {
6665 assert_eq!(
6666 panel.selected_agent, agent_a,
6667 "workspace A should restore agent-alpha, not agent-beta"
6668 );
6669 });
6670
6671 loaded_b.read_with(cx, |panel, _cx| {
6672 assert_eq!(
6673 panel.selected_agent, agent_b,
6674 "workspace B should restore agent-beta, not agent-alpha"
6675 );
6676 });
6677 }
6678
6679 #[gpui::test]
6680 async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
6681 init_test(cx);
6682 cx.update(|cx| {
6683 agent::ThreadStore::init_global(cx);
6684 language_model::LanguageModelRegistry::test(cx);
6685 });
6686
6687 let fs = FakeFs::new(cx.executor());
6688 let project = Project::test(fs.clone(), [], cx).await;
6689
6690 let multi_workspace =
6691 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6692
6693 let workspace = multi_workspace
6694 .read_with(cx, |multi_workspace, _cx| {
6695 multi_workspace.workspace().clone()
6696 })
6697 .unwrap();
6698
6699 workspace.update(cx, |workspace, _cx| {
6700 workspace.set_random_database_id();
6701 });
6702
6703 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6704
6705 let custom_agent = Agent::Custom {
6706 id: "my-custom-agent".into(),
6707 };
6708
6709 let panel = workspace.update_in(cx, |workspace, window, cx| {
6710 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6711 workspace.add_panel(panel.clone(), window, cx);
6712 panel
6713 });
6714
6715 // Set selected_agent to a custom agent
6716 panel.update(cx, |panel, _cx| {
6717 panel.selected_agent = custom_agent.clone();
6718 });
6719
6720 // Call new_thread, which internally calls external_thread(None, ...)
6721 // This resolves the agent from self.selected_agent
6722 panel.update_in(cx, |panel, window, cx| {
6723 panel.new_thread(&NewThread, window, cx);
6724 });
6725
6726 panel.read_with(cx, |panel, _cx| {
6727 assert_eq!(
6728 panel.selected_agent, custom_agent,
6729 "selected_agent should remain the custom agent after new_thread"
6730 );
6731 assert!(
6732 panel.active_conversation_view().is_some(),
6733 "a thread should have been created"
6734 );
6735 });
6736 }
6737
6738 #[gpui::test]
6739 async fn test_rollback_all_succeed_returns_ok(cx: &mut TestAppContext) {
6740 init_test(cx);
6741 let fs = FakeFs::new(cx.executor());
6742 cx.update(|cx| {
6743 cx.update_flags(true, vec!["agent-v2".to_string()]);
6744 agent::ThreadStore::init_global(cx);
6745 language_model::LanguageModelRegistry::test(cx);
6746 <dyn fs::Fs>::set_global(fs.clone(), cx);
6747 });
6748
6749 fs.insert_tree(
6750 "/project",
6751 json!({
6752 ".git": {},
6753 "src": { "main.rs": "fn main() {}" }
6754 }),
6755 )
6756 .await;
6757
6758 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6759 cx.executor().run_until_parked();
6760
6761 let repository = project.read_with(cx, |project, cx| {
6762 project.repositories(cx).values().next().unwrap().clone()
6763 });
6764
6765 let multi_workspace =
6766 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6767
6768 let path_a = PathBuf::from("/worktrees/branch/project_a");
6769 let path_b = PathBuf::from("/worktrees/branch/project_b");
6770
6771 let (sender_a, receiver_a) = futures::channel::oneshot::channel::<Result<()>>();
6772 let (sender_b, receiver_b) = futures::channel::oneshot::channel::<Result<()>>();
6773 sender_a.send(Ok(())).unwrap();
6774 sender_b.send(Ok(())).unwrap();
6775
6776 let creation_infos = vec![
6777 (repository.clone(), path_a.clone(), receiver_a),
6778 (repository.clone(), path_b.clone(), receiver_b),
6779 ];
6780
6781 let fs_clone = fs.clone();
6782 let result = multi_workspace
6783 .update(cx, |_, window, cx| {
6784 window.spawn(cx, async move |cx| {
6785 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6786 })
6787 })
6788 .unwrap()
6789 .await;
6790
6791 let paths = result.expect("all succeed should return Ok");
6792 assert_eq!(paths, vec![path_a, path_b]);
6793 }
6794
6795 #[gpui::test]
6796 async fn test_rollback_on_failure_attempts_all_worktrees(cx: &mut TestAppContext) {
6797 init_test(cx);
6798 let fs = FakeFs::new(cx.executor());
6799 cx.update(|cx| {
6800 cx.update_flags(true, vec!["agent-v2".to_string()]);
6801 agent::ThreadStore::init_global(cx);
6802 language_model::LanguageModelRegistry::test(cx);
6803 <dyn fs::Fs>::set_global(fs.clone(), cx);
6804 });
6805
6806 fs.insert_tree(
6807 "/project",
6808 json!({
6809 ".git": {},
6810 "src": { "main.rs": "fn main() {}" }
6811 }),
6812 )
6813 .await;
6814
6815 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6816 cx.executor().run_until_parked();
6817
6818 let repository = project.read_with(cx, |project, cx| {
6819 project.repositories(cx).values().next().unwrap().clone()
6820 });
6821
6822 // Actually create a worktree so it exists in FakeFs for rollback to find.
6823 let success_path = PathBuf::from("/worktrees/branch/project");
6824 cx.update(|cx| {
6825 repository.update(cx, |repo, _| {
6826 repo.create_worktree(
6827 git::repository::CreateWorktreeTarget::NewBranch {
6828 branch_name: "branch".to_string(),
6829 base_sha: None,
6830 },
6831 success_path.clone(),
6832 )
6833 })
6834 })
6835 .await
6836 .unwrap()
6837 .unwrap();
6838 cx.executor().run_until_parked();
6839
6840 // Verify the worktree directory exists before rollback.
6841 assert!(
6842 fs.is_dir(&success_path).await,
6843 "worktree directory should exist before rollback"
6844 );
6845
6846 let multi_workspace =
6847 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6848
6849 // Build creation_infos: one success, one failure.
6850 let failed_path = PathBuf::from("/worktrees/branch/failed_project");
6851
6852 let (sender_ok, receiver_ok) = futures::channel::oneshot::channel::<Result<()>>();
6853 let (sender_err, receiver_err) = futures::channel::oneshot::channel::<Result<()>>();
6854 sender_ok.send(Ok(())).unwrap();
6855 sender_err
6856 .send(Err(anyhow!("branch already exists")))
6857 .unwrap();
6858
6859 let creation_infos = vec![
6860 (repository.clone(), success_path.clone(), receiver_ok),
6861 (repository.clone(), failed_path.clone(), receiver_err),
6862 ];
6863
6864 let fs_clone = fs.clone();
6865 let result = multi_workspace
6866 .update(cx, |_, window, cx| {
6867 window.spawn(cx, async move |cx| {
6868 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6869 })
6870 })
6871 .unwrap()
6872 .await;
6873
6874 assert!(
6875 result.is_err(),
6876 "should return error when any creation fails"
6877 );
6878 let err_msg = result.unwrap_err().to_string();
6879 assert!(
6880 err_msg.contains("branch already exists"),
6881 "error should mention the original failure: {err_msg}"
6882 );
6883
6884 // The successful worktree should have been rolled back by git.
6885 cx.executor().run_until_parked();
6886 assert!(
6887 !fs.is_dir(&success_path).await,
6888 "successful worktree directory should be removed by rollback"
6889 );
6890 }
6891
6892 #[gpui::test]
6893 async fn test_rollback_on_canceled_receiver(cx: &mut TestAppContext) {
6894 init_test(cx);
6895 let fs = FakeFs::new(cx.executor());
6896 cx.update(|cx| {
6897 cx.update_flags(true, vec!["agent-v2".to_string()]);
6898 agent::ThreadStore::init_global(cx);
6899 language_model::LanguageModelRegistry::test(cx);
6900 <dyn fs::Fs>::set_global(fs.clone(), cx);
6901 });
6902
6903 fs.insert_tree(
6904 "/project",
6905 json!({
6906 ".git": {},
6907 "src": { "main.rs": "fn main() {}" }
6908 }),
6909 )
6910 .await;
6911
6912 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6913 cx.executor().run_until_parked();
6914
6915 let repository = project.read_with(cx, |project, cx| {
6916 project.repositories(cx).values().next().unwrap().clone()
6917 });
6918
6919 let multi_workspace =
6920 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6921
6922 let path = PathBuf::from("/worktrees/branch/project");
6923
6924 // Drop the sender to simulate a canceled receiver.
6925 let (_sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
6926 drop(_sender);
6927
6928 let creation_infos = vec![(repository.clone(), path.clone(), receiver)];
6929
6930 let fs_clone = fs.clone();
6931 let result = multi_workspace
6932 .update(cx, |_, window, cx| {
6933 window.spawn(cx, async move |cx| {
6934 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6935 })
6936 })
6937 .unwrap()
6938 .await;
6939
6940 assert!(
6941 result.is_err(),
6942 "should return error when receiver is canceled"
6943 );
6944 let err_msg = result.unwrap_err().to_string();
6945 assert!(
6946 err_msg.contains("canceled"),
6947 "error should mention cancellation: {err_msg}"
6948 );
6949 }
6950
6951 #[gpui::test]
6952 async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) {
6953 init_test(cx);
6954 let fs = FakeFs::new(cx.executor());
6955 cx.update(|cx| {
6956 cx.update_flags(true, vec!["agent-v2".to_string()]);
6957 agent::ThreadStore::init_global(cx);
6958 language_model::LanguageModelRegistry::test(cx);
6959 <dyn fs::Fs>::set_global(fs.clone(), cx);
6960 });
6961
6962 fs.insert_tree(
6963 "/project",
6964 json!({
6965 ".git": {},
6966 "src": { "main.rs": "fn main() {}" }
6967 }),
6968 )
6969 .await;
6970
6971 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6972 cx.executor().run_until_parked();
6973
6974 let repository = project.read_with(cx, |project, cx| {
6975 project.repositories(cx).values().next().unwrap().clone()
6976 });
6977
6978 let multi_workspace =
6979 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6980
6981 // Simulate the orphan state: create_dir_all was called but git
6982 // worktree add failed, leaving a directory with leftover files.
6983 let orphan_path = PathBuf::from("/worktrees/branch/orphan_project");
6984 fs.insert_tree(
6985 "/worktrees/branch/orphan_project",
6986 json!({ "leftover.txt": "junk" }),
6987 )
6988 .await;
6989
6990 assert!(
6991 fs.is_dir(&orphan_path).await,
6992 "orphan dir should exist before rollback"
6993 );
6994
6995 let (sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
6996 sender.send(Err(anyhow!("hook failed"))).unwrap();
6997
6998 let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)];
6999
7000 let fs_clone = fs.clone();
7001 let result = multi_workspace
7002 .update(cx, |_, window, cx| {
7003 window.spawn(cx, async move |cx| {
7004 AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7005 })
7006 })
7007 .unwrap()
7008 .await;
7009
7010 cx.executor().run_until_parked();
7011
7012 assert!(result.is_err());
7013 assert!(
7014 !fs.is_dir(&orphan_path).await,
7015 "orphan worktree directory should be removed by filesystem cleanup"
7016 );
7017 }
7018
7019 #[gpui::test]
7020 async fn test_worktree_creation_for_remote_project(
7021 cx: &mut TestAppContext,
7022 server_cx: &mut TestAppContext,
7023 ) {
7024 init_test(cx);
7025
7026 let app_state = cx.update(|cx| {
7027 agent::ThreadStore::init_global(cx);
7028 language_model::LanguageModelRegistry::test(cx);
7029
7030 let app_state = workspace::AppState::test(cx);
7031 workspace::init(app_state.clone(), cx);
7032 app_state
7033 });
7034
7035 server_cx.update(|cx| {
7036 release_channel::init(semver::Version::new(0, 0, 0), cx);
7037 });
7038
7039 // Set up the remote server side with a git repo.
7040 let server_fs = FakeFs::new(server_cx.executor());
7041 server_fs
7042 .insert_tree(
7043 "/project",
7044 json!({
7045 ".git": {},
7046 "src": {
7047 "main.rs": "fn main() {}"
7048 }
7049 }),
7050 )
7051 .await;
7052 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
7053
7054 // Create a mock remote connection.
7055 let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
7056
7057 server_cx.update(remote_server::HeadlessProject::init);
7058 let server_executor = server_cx.executor();
7059 let _headless = server_cx.new(|cx| {
7060 remote_server::HeadlessProject::new(
7061 remote_server::HeadlessAppState {
7062 session: server_session,
7063 fs: server_fs.clone(),
7064 http_client: Arc::new(http_client::BlockedHttpClient),
7065 node_runtime: node_runtime::NodeRuntime::unavailable(),
7066 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
7067 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
7068 startup_time: Instant::now(),
7069 },
7070 false,
7071 cx,
7072 )
7073 });
7074
7075 // Connect the client side and build a remote project.
7076 // Use a separate Client to avoid double-registering proto handlers
7077 // (Workspace::test_new creates its own WorkspaceStore from the
7078 // project's client).
7079 let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
7080 let project = cx.update(|cx| {
7081 let project_client = client::Client::new(
7082 Arc::new(clock::FakeSystemClock::new()),
7083 http_client::FakeHttpClient::with_404_response(),
7084 cx,
7085 );
7086 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
7087 project::Project::remote(
7088 remote_client,
7089 project_client,
7090 node_runtime::NodeRuntime::unavailable(),
7091 user_store,
7092 app_state.languages.clone(),
7093 app_state.fs.clone(),
7094 false,
7095 cx,
7096 )
7097 });
7098
7099 // Open the remote path as a worktree in the project.
7100 let worktree_path = Path::new("/project");
7101 project
7102 .update(cx, |project, cx| {
7103 project.find_or_create_worktree(worktree_path, true, cx)
7104 })
7105 .await
7106 .expect("should be able to open remote worktree");
7107 cx.run_until_parked();
7108
7109 // Verify the project is indeed remote.
7110 project.read_with(cx, |project, cx| {
7111 assert!(!project.is_local(), "project should be remote, not local");
7112 assert!(
7113 project.remote_connection_options(cx).is_some(),
7114 "project should have remote connection options"
7115 );
7116 });
7117
7118 // Create the workspace and agent panel.
7119 let multi_workspace =
7120 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7121 multi_workspace
7122 .update(cx, |multi_workspace, _, cx| {
7123 multi_workspace.open_sidebar(cx);
7124 })
7125 .unwrap();
7126
7127 let workspace = multi_workspace
7128 .read_with(cx, |mw, _cx| mw.workspace().clone())
7129 .unwrap();
7130
7131 workspace.update(cx, |workspace, _cx| {
7132 workspace.set_random_database_id();
7133 });
7134
7135 // Register a callback so new workspaces also get an AgentPanel.
7136 cx.update(|cx| {
7137 cx.observe_new(
7138 |workspace: &mut Workspace,
7139 window: Option<&mut Window>,
7140 cx: &mut Context<Workspace>| {
7141 if let Some(window) = window {
7142 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7143 workspace.add_panel(panel, window, cx);
7144 }
7145 },
7146 )
7147 .detach();
7148 });
7149
7150 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7151 cx.run_until_parked();
7152
7153 let panel = workspace.update_in(cx, |workspace, window, cx| {
7154 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7155 workspace.add_panel(panel.clone(), window, cx);
7156 panel
7157 });
7158
7159 cx.run_until_parked();
7160
7161 // Open a thread.
7162 panel.update_in(cx, |panel, window, cx| {
7163 panel.open_external_thread_with_server(
7164 Rc::new(StubAgentServer::default_response()),
7165 window,
7166 cx,
7167 );
7168 });
7169 cx.run_until_parked();
7170
7171 // Set start_thread_in to LinkedWorktree to bypass git worktree
7172 // creation and directly test workspace opening for a known path.
7173 let linked_path = PathBuf::from("/project");
7174 panel.update_in(cx, |panel, window, cx| {
7175 panel.set_start_thread_in(
7176 &StartThreadIn::LinkedWorktree {
7177 path: linked_path.clone(),
7178 display_name: "project".to_string(),
7179 },
7180 window,
7181 cx,
7182 );
7183 });
7184
7185 // Trigger worktree creation.
7186 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
7187 "Hello from remote test",
7188 ))];
7189 panel.update_in(cx, |panel, window, cx| {
7190 panel.handle_worktree_requested(
7191 content,
7192 WorktreeCreationArgs::Linked {
7193 worktree_path: linked_path,
7194 },
7195 window,
7196 cx,
7197 );
7198 });
7199
7200 // The refactored code uses `find_or_create_workspace`, which
7201 // finds the existing remote workspace (matching paths + host)
7202 // and reuses it instead of creating a new connection.
7203 cx.run_until_parked();
7204
7205 // The task should have completed: the existing workspace was
7206 // found and reused.
7207 panel.read_with(cx, |panel, _cx| {
7208 assert!(
7209 panel.worktree_creation_status.is_none(),
7210 "worktree creation should have completed, but status is: {:?}",
7211 panel.worktree_creation_status
7212 );
7213 });
7214
7215 // The existing remote workspace was reused — no new workspace
7216 // should have been created.
7217 multi_workspace
7218 .read_with(cx, |multi_workspace, cx| {
7219 let project = workspace.read(cx).project().clone();
7220 assert!(
7221 !project.read(cx).is_local(),
7222 "workspace project should still be remote, not local"
7223 );
7224 assert_eq!(
7225 multi_workspace.workspaces().count(),
7226 1,
7227 "existing remote workspace should be reused, not a new one created"
7228 );
7229 })
7230 .unwrap();
7231 }
7232}