fix(native-chat): load skills in structured sessions

This commit is contained in:
Brennan Benson
2026-08-14 18:06:42 -07:00
parent ed38880bc2
commit ecc95bdfe2
5 changed files with 104 additions and 6 deletions
@@ -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(
<NativeChatPickerMenu
autocomplete={autocomplete({ items: [], skillStatus: 'error', skillErrorKind: 'host' })}
activeIndex={0}
listboxId="picker"
onChoose={vi.fn()}
onRetry={onRetry}
/>
)
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(
<NativeChatPickerMenu
@@ -20,6 +20,7 @@ export type NativeChatSkillStateInputs = Pick<
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
| 'settings'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'worktreesByRepo'
>
@@ -27,6 +28,7 @@ type NativeChatSkillTab = { id: string; startupCwd?: string }
type NativeChatSkillWorktreeState = {
tabsByWorktree: Record<string, readonly NativeChatSkillTab[]>
unifiedTabsByWorktree?: Record<string, readonly { id: string }[]>
worktreesByRepo: Record<string, readonly { id: string; path: string }[]>
}
@@ -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<NativeChatSkillWorktreeState, 'tabsByWorktree' | 'unifiedTabsByWorktree'>,
tabId: string
): { worktreeId: string; tab: NativeChatSkillTab } | null {
return (
findTerminalTab(state.tabsByWorktree, tabId) ??
findTerminalTab(state.unifiedTabsByWorktree ?? {}, tabId)
)
}
function findTerminalTab(
tabsByWorktree: Record<string, readonly NativeChatSkillTab[]>,
terminalTabId: string
@@ -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(<Probe enabled />)
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(<Probe enabled />)
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(<Probe enabled />)
expect(mocks.snapshots.at(-1)?.status).toBe('error')
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
it('shares one in-flight request between sibling panes', async () => {
render(
<>
@@ -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')
@@ -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<string, Promise<SkillDiscoveryResult>>()
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(() => {