From ecc95bdfe266795d646203d4eddf55e3bc2aba8e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:06:42 -0700 Subject: [PATCH] fix(native-chat): load skills in structured sessions --- .../NativeChatAutocompleteMenus.test.tsx | 18 ++++++++ .../native-chat-skill-discovery-context.ts | 17 +++++++- .../use-native-chat-skills.react.test.tsx | 41 +++++++++++++++++++ .../use-native-chat-skills.test.ts | 17 ++++++++ .../native-chat/use-native-chat-skills.ts | 17 ++++++-- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 74476af1fb2..d5dac1efab5 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -89,6 +89,24 @@ describe('NativeChatPickerMenu', () => { expect(screen.getAllByText('Loading skills...')).toHaveLength(2) }) + it('renders a retryable error instead of the loading spinner when discovery fails', () => { + const onRetry = vi.fn() + render( + + ) + + expect(screen.getAllByText('Could not load skills from this host')).toHaveLength(2) + expect(screen.queryByText('Loading skills...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledOnce() + }) + it('uses command-only empty copy for a picker without skill support', () => { render( @@ -27,6 +28,7 @@ type NativeChatSkillTab = { id: string; startupCwd?: string } type NativeChatSkillWorktreeState = { tabsByWorktree: Record + unifiedTabsByWorktree?: Record worktreesByRepo: Record } @@ -49,6 +51,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, tabsByWorktree: state.tabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktreesByRepo: state.worktreesByRepo } } @@ -57,7 +60,7 @@ export function resolveNativeChatSkillDiscoveryCwd( state: NativeChatSkillWorktreeState, terminalTabId: string ): string | null { - const found = findTerminalTab(state.tabsByWorktree, terminalTabId) + const found = findNativeChatTab(state, terminalTabId) if (!found) { return null } @@ -80,7 +83,7 @@ export function resolveNativeChatSkillDiscoveryContext( state: NativeChatSkillStateInputs, terminalTabId: string ): NativeChatSkillDiscoveryContext | null { - const worktreeId = findTerminalTab(state.tabsByWorktree, terminalTabId)?.worktreeId ?? null + const worktreeId = findNativeChatTab(state, terminalTabId)?.worktreeId ?? null if (!worktreeId) { return null } @@ -142,6 +145,16 @@ export function resolveNativeChatSkillDiscoveryContext( } } +function findNativeChatTab( + state: Pick, + tabId: string +): { worktreeId: string; tab: NativeChatSkillTab } | null { + return ( + findTerminalTab(state.tabsByWorktree, tabId) ?? + findTerminalTab(state.unifiedTabsByWorktree ?? {}, tabId) + ) +} + function findTerminalTab( tabsByWorktree: Record, terminalTabId: string diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index a436ba3c7ad..f360394cf37 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -44,6 +44,7 @@ function stateForHost(hostId: string) { restoredRuntimeHostIdByWorkspaceSessionKey: {}, settings: { activeRuntimeEnvironmentId: null }, tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + unifiedTabsByWorktree: {}, worktreesByRepo: { 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId }] } @@ -111,6 +112,46 @@ describe('useNativeChatSkills', () => { ) }) + it('resolves the catalog for a structured session tab', async () => { + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + unifiedTabsByWorktree: { + 'worktree-1': [{ id: 'tab-1', contentType: 'agent-session', entityId: 'session-1' }] + } + } + render() + + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) + + it('surfaces discovery failure instead of remaining loading', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed')) + render() + + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('error')) + expect(mocks.snapshots.at(-1)?.error?.message).toBe('scan failed') + }) + + it('surfaces missing tab ownership instead of remaining loading', () => { + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + } + render() + + expect(mocks.snapshots.at(-1)?.status).toBe('error') + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + it('shares one in-flight request between sibling panes', async () => { render( <> diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 6e5daaf43db..9baf747673a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -132,6 +132,23 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { ).toBe('/repo/worktree') }) + it('returns the owning worktree path for a structured session tab', () => { + expect( + resolveNativeChatSkillDiscoveryCwd( + { + tabsByWorktree: {}, + unifiedTabsByWorktree: { + 'repo-1::/repo/worktree': [{ id: 'structured-tab-1' }] + }, + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/worktree', path: '/repo/worktree' }] + } + }, + 'structured-tab-1' + ) + ).toBe('/repo/worktree') + }) + it('returns null when the tab has no known worktree owner', () => { expect( resolveNativeChatSkillDiscoveryCwd({ tabsByWorktree: {}, worktreesByRepo: {} }, 'tab-1') diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index f8a7e943935..42adc911656 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -43,6 +43,13 @@ const IDLE_STATE: StoredDiscoveryState = { error: null, contextKey: null } +const MISSING_CONTEXT_STATE: StoredDiscoveryState = { + status: 'error', + skills: [], + error: new Error('Skill discovery context is unavailable.'), + errorKind: 'unknown', + contextKey: null +} const inFlightDiscovery = new Map>() export function isNativeChatSkillForAgent( @@ -175,11 +182,13 @@ export function useNativeChatSkills( const effectiveState = useMemo( () => - !profile || !enabled || !context + !profile || !enabled ? IDLE_STATE - : state.contextKey === context.key - ? state - : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, + : !context + ? MISSING_CONTEXT_STATE + : state.contextKey === context.key + ? state + : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, [context, enabled, profile, state] ) const visibleSkills = useMemo(() => {