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