From 153c753cf748c5d0f5b863d59bc87283c3cb352c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 7 Sep 2026 09:18:31 +0800 Subject: [PATCH] fix(skills): reuse picker loads and bound parallel remote discovery --- .../tools/implementations/skills/registry.rs | 293 ++++++++++++++---- .../src/flow_chat/components/ChatInput.tsx | 112 +++---- .../hooks/useResolvedModeSkills.test.tsx | 119 +++++++ .../flow_chat/hooks/useResolvedModeSkills.ts | 74 +++++ 4 files changed, 463 insertions(+), 135 deletions(-) create mode 100644 src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.test.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 5d3c11c2d9..0a3c5deb58 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -17,6 +17,7 @@ use crate::external_sources::{ }; use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use futures::{stream, StreamExt}; use log::{debug, error, warn}; use openbitfun_agent_runtime::skills::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, @@ -57,6 +58,9 @@ const MAX_OPENCODE_CONFIGURED_POLICY_BYTES: usize = 64 * 1024; const OPENCODE_CONFIGURED_PRIORITY_BAND: usize = MAX_OPENCODE_CONFIGURED_SKILL_ROOTS * MAX_OPENCODE_CONFIGURED_SKILLS_PER_ROOT; +// Bound remote IO across the whole scan, including workspaces with many roots. +const REMOTE_SKILL_SCAN_CONCURRENCY: usize = 4; + const DEEP_RESEARCH_AGENT_ID: &str = "DeepResearch"; const DEEP_RESEARCH_SKILL_NAME: &str = "deep-research"; @@ -1244,75 +1248,90 @@ impl SkillRegistry { fs: &dyn WorkspaceFileSystem, remote_root: &str, ) -> Vec { - let mut roots = Vec::new(); let root = remote_root.trim_end_matches('/'); - for (priority, spec) in PROJECT_SKILL_ROOTS.iter().enumerate() { - let path = format!("{}/{}/{}", root, spec.parent, spec.subdir); - if fs.is_dir(&path).await.unwrap_or(false) { - roots.push(RemoteSkillRootEntry { + // Finish directory discovery before scanning files, so the two bounded + // stages cannot multiply the number of simultaneous SFTP operations. + // `buffered` preserves source precedence and sorted directory order even + // when responses complete in a different order. + let root_scans = PROJECT_SKILL_ROOTS + .iter() + .enumerate() + .map(|(priority, spec)| async move { + let path = format!("{}/{}/{}", root, spec.parent, spec.subdir); + let entry = RemoteSkillRootEntry { path, slot: spec.slot, source_id: spec.source_id, source_label: spec.source_label, priority, - }); - } - } - - let mut skills = Vec::new(); - for entry in roots { - let mut entries = match fs.read_dir(&entry.path).await { - Ok(value) => value, - Err(_) => continue, - }; - sort_remote_dir_entries(&mut entries); - - for item in entries { - if !item.is_dir || item.is_symlink { - continue; - } - - let Some(dir_name) = normalize_remote_skill_dir_name(&item.path) else { - continue; }; + let mut items = if fs.is_dir(&entry.path).await.unwrap_or(false) { + fs.read_dir(&entry.path).await.unwrap_or_default() + } else { + Vec::new() + }; + sort_remote_dir_entries(&mut items); + (entry, items) + }) + .collect::>(); + let roots = stream::iter(root_scans) + .buffered(REMOTE_SKILL_SCAN_CONCURRENCY) + .collect::>() + .await; + + let directories = roots.iter().flat_map(|(entry, items)| { + items + .iter() + .filter(|item| item.is_dir && !item.is_symlink) + .map(move |item| (entry, item)) + }); + let skill_scans = directories + .map(|(entry, item)| async move { + let dir_name = normalize_remote_skill_dir_name(&item.path)?; let skill_md_path = format!("{}/SKILL.md", item.path.trim_end_matches('/')); if !fs.is_file(&skill_md_path).await.unwrap_or(false) { - continue; + return None; } - - match fs.read_file_text(&skill_md_path).await { - Ok(content) => match Self::parse_skill_markdown( - item.path.clone(), - &content, - SkillLocation::Project, - false, - entry.slot, - ) { - Ok(mut skill_data) => { - Self::apply_remote_openai_policy(&mut skill_data, fs, &item.path).await; - skill_data.dir_name = dir_name; - skills.push(SkillCandidate::from_data( - skill_data, - entry.slot, - entry.source_id, - entry.source_label, - PROJECT_SKILL_KEY_PREFIX, - entry.priority, - false, - )); - } - Err(error) => { - error!("Failed to parse SKILL.md in {}: {}", item.path, error); - } - }, + let content = match fs.read_file_text(&skill_md_path).await { + Ok(content) => content, Err(error) => { debug!("Failed to read {}: {}", skill_md_path, error); + return None; } - } - } - } - - skills + }; + let mut skill_data = match Self::parse_skill_markdown( + item.path.clone(), + &content, + SkillLocation::Project, + false, + entry.slot, + ) { + Ok(data) => data, + Err(error) => { + error!("Failed to parse SKILL.md in {}: {}", item.path, error); + return None; + } + }; + Self::apply_remote_openai_policy(&mut skill_data, fs, &item.path).await; + skill_data.dir_name = dir_name; + Some(SkillCandidate::from_data( + skill_data, + entry.slot, + entry.source_id, + entry.source_label, + PROJECT_SKILL_KEY_PREFIX, + entry.priority, + false, + )) + }) + .collect::>(); + stream::iter(skill_scans) + .buffered(REMOTE_SKILL_SCAN_CONCURRENCY) + .collect::>() + .await + .into_iter() + .flatten() + .collect() } async fn scan_skill_candidates_for_remote_workspace( @@ -1320,8 +1339,11 @@ impl SkillRegistry { fs: &dyn WorkspaceFileSystem, remote_root: &str, ) -> Vec { - let mut skills = self.scan_skill_candidates_for_workspace(None).await; - skills.extend(Self::scan_remote_project_skills(fs, remote_root).await); + let (mut skills, project_skills) = tokio::join!( + self.scan_skill_candidates_for_workspace(None), + Self::scan_remote_project_skills(fs, remote_root), + ); + skills.extend(project_skills); skills } @@ -2212,3 +2234,158 @@ mod opencode_configured_skill_tests { std::os::windows::fs::symlink_dir(target, link).is_ok() } } + +#[cfg(test)] +mod remote_scan_tests { + use super::SkillRegistry; + use crate::agentic::workspace::{WorkspaceDirEntry, WorkspaceFileSystem}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + #[derive(Default)] + struct DelayedFs { + active: AtomicUsize, + peak: AtomicUsize, + calls: AtomicUsize, + } + + impl DelayedFs { + async fn round_trip(&self) { + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(active, Ordering::SeqCst); + self.calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(10)).await; + self.active.fetch_sub(1, Ordering::SeqCst); + } + } + + #[async_trait::async_trait] + impl WorkspaceFileSystem for DelayedFs { + async fn read_file(&self, path: &str) -> anyhow::Result> { + Ok(self.read_file_text(path).await?.into_bytes()) + } + async fn read_file_text(&self, path: &str) -> anyhow::Result { + self.round_trip().await; + if path.ends_with("openai.yaml") { + return Ok("policy:\n allow_implicit_invocation: false\n".into()); + } + let name = path.rsplit('/').nth(1).unwrap(); + Ok(format!( + "---\nname: {name}\ndescription: {path}\n---\nBody\n" + )) + } + async fn write_file(&self, _: &str, _: &[u8]) -> anyhow::Result<()> { + anyhow::bail!("read-only fixture") + } + async fn exists(&self, path: &str) -> anyhow::Result { + self.is_file(path).await + } + async fn is_file(&self, path: &str) -> anyhow::Result { + self.round_trip().await; + Ok(path.ends_with("SKILL.md") || path.ends_with("skill-00/agents/openai.yaml")) + } + async fn is_dir(&self, path: &str) -> anyhow::Result { + self.round_trip().await; + Ok(path.contains("/.openbitfun/") || path.contains("/.codex/")) + } + async fn read_dir(&self, path: &str) -> anyhow::Result> { + self.round_trip().await; + Ok((0..13) + .rev() + .map(|index| WorkspaceDirEntry { + name: format!("skill-{index:02}"), + path: format!("{path}/skill-{index:02}"), + is_dir: true, + is_symlink: index == 12, + modified: None, + }) + .collect()) + } + } + + #[tokio::test] + async fn remote_scan_preserves_order_and_policy_with_bounded_io() { + let fs = DelayedFs::default(); + let start = Instant::now(); + let skills = SkillRegistry::scan_remote_project_skills(&fs, "/remote/project/").await; + eprintln!( + "remote scan: {:?}, {} logical IO calls, peak {}", + start.elapsed(), + fs.calls.load(Ordering::SeqCst), + fs.peak.load(Ordering::SeqCst) + ); + assert_eq!(skills.len(), 24); + for group in skills.chunks(12) { + assert_eq!( + group + .iter() + .map(|skill| skill.info.name.clone()) + .collect::>(), + (0..12) + .map(|index| format!("skill-{index:02}")) + .collect::>() + ); + assert!(!group[0].info.allow_implicit_invocation); + assert!(group[1].info.allow_implicit_invocation); + } + assert!(skills[0].priority < skills[12].priority); + assert_eq!(fs.calls.load(Ordering::SeqCst), 82); + assert_eq!(fs.active.load(Ordering::SeqCst), 0); + assert!(fs.peak.load(Ordering::SeqCst) > 1); + assert!(fs.peak.load(Ordering::SeqCst) <= super::REMOTE_SKILL_SCAN_CONCURRENCY); + + // The same project catalog on disk must retain the remote scan's source + // precedence and invocation policy, without involving user-global skills. + let local_root = tempfile::tempdir().unwrap(); + for parent in [".openbitfun", ".codex"] { + for index in 0..12 { + let name = format!("skill-{index:02}"); + let dir = local_root.path().join(parent).join("skills").join(&name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: fixture\n---\nBody\n"), + ) + .unwrap(); + if index == 0 { + std::fs::create_dir_all(dir.join("agents")).unwrap(); + std::fs::write( + dir.join("agents/openai.yaml"), + "policy:\n allow_implicit_invocation: false\n", + ) + .unwrap(); + } + } + } + let start = Instant::now(); + let mut local = Vec::new(); + for entry in SkillRegistry::get_project_skill_roots(local_root.path()) { + local.extend(SkillRegistry::scan_skills_in_dir(&entry).await); + } + eprintln!( + "local project scan: {:?}, {} skills", + start.elapsed(), + local.len() + ); + let catalog = |candidates: Vec| { + candidates + .into_iter() + .map(|candidate| { + ( + candidate.info.key, + candidate.info.name, + candidate.info.allow_implicit_invocation, + candidate.priority, + ) + }) + .collect::>() + }; + // Local directory enumeration is sorted by the shared resolver later. + local.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then(a.info.name.cmp(&b.info.name)) + }); + assert_eq!(catalog(local), catalog(skills)); + } +} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 81596f4430..bb08f61d58 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -153,7 +153,7 @@ import { } from '@/infrastructure/config'; import { useComputerUseEnabled } from '@/infrastructure/config/hooks/useComputerUseEnabled'; import type { ToolPermissionConfig } from '@/infrastructure/config/types'; -import type { ModeSkillInfo } from '@/infrastructure/config/types'; +import { useResolvedModeSkills } from '../hooks/useResolvedModeSkills'; import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import MCPAPI, { type MCPPrompt, type MCPPromptMessage, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; import { @@ -1201,10 +1201,6 @@ export const ChatInput: React.FC = ({ const suppressNextUserDefaultModeApplicationRef = useRef(false); const openScene = useSceneStore(s => s.openScene); - const [resolvedModeSkills, setResolvedModeSkills] = useState([]); - const [resolvedModeSkillsLoading, setResolvedModeSkillsLoading] = useState(false); - const [resolvedModeSkillsLoadFailed, setResolvedModeSkillsLoadFailed] = useState(false); - const [resolvedModeSkillsRequestVersion, setResolvedModeSkillsRequestVersion] = useState(0); const [subagentToolInfo, setSubagentToolInfo] = useState(null); const [targetModeEnabledTools, setTargetModeEnabledTools] = useState(null); const [targetModeToolsResolved, setTargetModeToolsResolved] = useState(false); @@ -1212,11 +1208,6 @@ export const ChatInput: React.FC = ({ const { computerUseEnabled } = useComputerUseEnabled(); const setChatInputHeight = useChatInputState(state => state.setInputHeight); - const userInvocableSkills = useMemo( - // Management keeps the full catalog; invocation surfaces apply both runtime and author visibility. - () => resolvedModeSkills.filter(isSkillAvailableForUserInvocation), - [resolvedModeSkills] - ); useEffect(() => { const store = FlowChatStore.getInstance(); @@ -1468,6 +1459,39 @@ export const ChatInput: React.FC = ({ }), [effectiveSendAgentType, isSubagentInputTarget, targetSkillToolAgents], ); + const [slashCommandState, setSlashCommandState] = useState<{ + isActive: boolean; + kind: 'actions' | 'all' | 'skills'; + query: string; + selectedIndex: number; + }>({ + isActive: false, + kind: 'all', + query: '', + selectedIndex: 0, + }); + const { + skills: resolvedModeSkills, + loading: resolvedModeSkillsLoading, + hasLoaded: resolvedModeSkillsLoaded, + failed: resolvedModeSkillsLoadFailed, + retry: retryResolvedModeSkills, + } = useResolvedModeSkills({ + enabled: isSceneActive && canUseSkillsForTarget && ( + isModeDropdownOpen || + (slashCommandState.isActive && (slashCommandState.kind === 'all' || slashCommandState.kind === 'skills')) + ), + surfaceEpoch: deviceSurfaceScope.epoch, + connectionId: sessionBoundRemoteConnectionId, + modeId: effectiveSendAgentType, + workspacePath: targetWorkspacePath, + }); + const userInvocableSkills = useMemo( + // Management keeps the full catalog; invocation surfaces apply both runtime and author visibility. + () => resolvedModeSkills.filter(isSkillAvailableForUserInvocation), + [resolvedModeSkills] + ); + const quickSkillShortcuts = useMemo( () => canUseSkillsForTarget ? resolveChatInputQuickSkillShortcuts(resolvedModeSkills) @@ -1681,17 +1705,6 @@ export const ChatInput: React.FC = ({ startOffset: 0, }); - const [slashCommandState, setSlashCommandState] = useState<{ - isActive: boolean; - kind: 'actions' | 'all' | 'skills'; - query: string; - selectedIndex: number; - }>({ - isActive: false, - kind: 'all', - query: '', - selectedIndex: 0, - }); const slashCommandPickerLayout = useAnchoredPopoverPosition({ open: slashCommandState.isActive, anchorRef: mentionAnchorRef, @@ -3000,61 +3013,6 @@ export const ChatInput: React.FC = ({ }; }, [modeState.dropdownOpen]); - const shouldLoadResolvedModeSkills = canUseSkillsForTarget && ( - isModeDropdownOpen || - (slashCommandState.isActive && (slashCommandState.kind === 'all' || slashCommandState.kind === 'skills')) - ); - const skillResolutionModeId = effectiveSendAgentType; - - useEffect(() => { - if (!shouldLoadResolvedModeSkills) { - setResolvedModeSkills([]); - setResolvedModeSkillsLoading(false); - setResolvedModeSkillsLoadFailed(false); - return; - } - let cancelled = false; - setResolvedModeSkillsLoading(true); - setResolvedModeSkillsLoadFailed(false); - (async () => { - try { - const list = await configAPI.getModeSkillConfigs({ - modeId: skillResolutionModeId, - workspacePath: targetWorkspacePath || undefined, - }); - if (!cancelled) { - setResolvedModeSkills(list); - } - } catch (err) { - log.error('Failed to load mode-resolved skills for chat input', { - err, - modeId: skillResolutionModeId, - workspacePath: targetWorkspacePath || undefined, - }); - if (!cancelled) { - setResolvedModeSkills([]); - setResolvedModeSkillsLoadFailed(true); - } - } finally { - if (!cancelled) { - setResolvedModeSkillsLoading(false); - } - } - })(); - return () => { - cancelled = true; - }; - }, [ - resolvedModeSkillsRequestVersion, - shouldLoadResolvedModeSkills, - skillResolutionModeId, - targetWorkspacePath, - ]); - - const retryResolvedModeSkills = useCallback(() => { - setResolvedModeSkillsRequestVersion(version => version + 1); - }, []); - React.useEffect(() => { if (!effectiveTargetSessionId || !sessionBoundWorkspacePath) { return; @@ -6466,7 +6424,7 @@ export const ChatInput: React.FC = ({ open={activeBoostSubmenu === 'skills'} onOpenChange={open => setBoostSubmenuOpen('skills', open)} > - {resolvedModeSkillsLoading ? ( + {resolvedModeSkillsLoading && !resolvedModeSkillsLoaded ? (
{t('chatInput.boostSkillsLoading')} diff --git a/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.test.tsx b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.test.tsx new file mode 100644 index 0000000000..b73d10b937 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.test.tsx @@ -0,0 +1,119 @@ +/** @vitest-environment jsdom */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ModeSkillInfo } from '@/infrastructure/config/types'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { useResolvedModeSkills } from './useResolvedModeSkills'; + +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ + configAPI: { getModeSkillConfigs: vi.fn() }, +})); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) })); + +const skills = [{ name: 'review', key: 'review' }] as ModeSkillInfo[]; +function deferred() { + let resolve!: (value: ModeSkillInfo[]) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +describe('useResolvedModeSkills', () => { + let root: Root; + let container: HTMLDivElement; + let latest: ReturnType; + let props: Parameters[0]; + const requests: ReturnType[] = []; + function Probe() { + latest = useResolvedModeSkills(props); + return null; + } + async function render(update = {}) { + props = { ...props, ...update }; + await act(async () => root.render()); + } + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + root = createRoot(container); + props = { enabled: true, surfaceEpoch: 1, modeId: 'agent', workspacePath: '/project' }; + requests.length = 0; + vi.mocked(configAPI.getModeSkillConfigs).mockReset().mockImplementation(() => { + const request = deferred(); + requests.push(request); + return request.promise; + }); + }); + afterEach(async () => { await act(async () => root.unmount()); }); + + it('retains a pending request across close/reopen and accepts completion while closed', async () => { + await render(); + expect(latest.loading).toBe(true); + await render({ enabled: false }); + await render({ enabled: true }); + expect(requests).toHaveLength(1); + await render({ enabled: false }); + await act(async () => requests[0].resolve(skills)); + expect(latest.skills).toEqual(skills); + await render({ enabled: true }); + expect(requests).toHaveLength(2); + expect(latest.skills).toEqual(skills); + expect(latest.hasLoaded).toBe(true); + expect(latest.loading).toBe(true); + await act(async () => requests[1].resolve([])); + expect(latest.skills).toEqual([]); + expect(latest.hasLoaded).toBe(true); + }); + + it.each([ + { workspacePath: '/different' }, + { modeId: 'plan' }, + { connectionId: 'other-ssh-host' }, + { surfaceEpoch: 2 }, + ])('isolates scope changes %j and ignores late responses', async update => { + await render(); + await render(update); + expect(requests).toHaveLength(2); + await act(async () => requests[0].resolve(skills)); + expect(latest.skills).toEqual([]); + expect(latest.hasLoaded).toBe(false); + await act(async () => requests[1].resolve([])); + expect(latest.skills).toEqual([]); + expect(latest.hasLoaded).toBe(true); + }); + + it('hides cached data on a scope switch while closed', async () => { + await render(); + await act(async () => requests[0].resolve(skills)); + await render({ enabled: false, surfaceEpoch: 2 }); + expect(latest.skills).toEqual([]); + expect(requests).toHaveLength(1); + }); + + it('does not fetch until enabled and retries errors without discarding a pending request', async () => { + await render({ enabled: false }); + expect(requests).toHaveLength(0); + await render({ enabled: true }); + await act(async () => latest.retry()); + expect(requests).toHaveLength(1); + await act(async () => requests[0].reject(new Error('offline'))); + expect(latest.failed).toBe(true); + expect(latest.loading).toBe(false); + await act(async () => latest.retry()); + expect(requests).toHaveLength(2); + await act(async () => requests[1].resolve(skills)); + expect(latest.failed).toBe(false); + expect(latest.skills).toEqual(skills); + }); + + it('surfaces background refresh failure instead of retaining stale policy indefinitely', async () => { + await render(); + await act(async () => requests[0].resolve(skills)); + await render({ enabled: false }); + await render({ enabled: true }); + await act(async () => requests[1].reject(new Error('offline'))); + expect(latest.skills).toEqual([]); + expect(latest.failed).toBe(true); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts new file mode 100644 index 0000000000..72baab3a14 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import type { ModeSkillInfo } from '@/infrastructure/config/types'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('useResolvedModeSkills'); +const EMPTY_SKILLS: ModeSkillInfo[] = []; + +type Snapshot = { + key: string; + skills: ModeSkillInfo[] | null; + loading: boolean; + failed: boolean; +}; + +export function useResolvedModeSkills({ + enabled, + surfaceEpoch, + connectionId, + modeId, + workspacePath, +}: { + enabled: boolean; + surfaceEpoch: number; + connectionId?: string | null; + modeId: string; + workspacePath?: string | null; +}) { + const key = JSON.stringify([surfaceEpoch, connectionId ?? null, workspacePath || null, modeId]); + const entryRef = useRef(null); + const mountedRef = useRef(false); + const [snapshot, setSnapshot] = useState(null); + const [revision, setRevision] = useState(0); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + useEffect(() => { + if (entryRef.current?.key !== key) { + entryRef.current = { key, skills: null, loading: false, failed: false }; + } + const entry = entryRef.current; + if (!enabled || entry.loading) return; + + // Keep a single in-flight request when a picker closes/reopens. Each later + // opening revalidates source files and mode policy, displaying the last result. + entry.loading = true; + entry.failed = false; + setSnapshot({ ...entry }); + void configAPI.getModeSkillConfigs({ modeId, workspacePath: workspacePath || undefined }) + .then(skills => { entry.skills = skills; }) + .catch(err => { + entry.skills = null; + entry.failed = true; + log.error('Failed to load mode-resolved skills for chat input', { err, modeId, workspacePath }); + }) + .finally(() => { + entry.loading = false; + if (mountedRef.current && entryRef.current === entry) setSnapshot({ ...entry }); + }); + }, [enabled, key, modeId, workspacePath, revision]); + + // Hide a previous host/workspace/mode synchronously, before effects run. + const current = snapshot?.key === key ? snapshot : null; + return { + skills: current?.skills ?? EMPTY_SKILLS, + loading: current?.loading ?? enabled, + hasLoaded: current?.skills != null, + failed: current?.failed ?? false, + retry: useCallback(() => setRevision(value => value + 1), []), + }; +}