fix(skills): surface failed setup commands and retry them (#11630)

* fix(skills): surface failed setup commands and retry them

* fix(skills): preserve failed setup diagnostics

* fix(skills): retire failed terminal before retry
This commit is contained in:
Brennan Benson
2026-07-30 22:44:49 -07:00
committed by GitHub
parent e1092291ae
commit 0ffdb79d34
11 changed files with 503 additions and 94 deletions
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal'
import {
ORCA_TERMINAL_COMMAND_FINISHED_EVENT,
type TerminalCommandFinishedEventDetail
} from '@/hooks/terminal-command-finished-event'
const mocks = vi.hoisted(() => ({
createTab: vi.fn(() => ({ id: 'tab-1' })),
closeTab: vi.fn(),
setActiveTabForWorktree: vi.fn(),
setTabCustomTitle: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
createTab: mocks.createTab,
closeTab: mocks.closeTab,
setActiveTabForWorktree: mocks.setActiveTabForWorktree,
setTabCustomTitle: mocks.setTabCustomTitle
})
}))
vi.mock('@/components/terminal-pane/TerminalPane', () => ({
default: () => <div data-testid="terminal-pane" />
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
}))
function dispatchCommandFinished(worktreeId: string, exitCode: number | null): void {
window.dispatchEvent(
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
detail: { worktreeId, exitCode }
})
)
}
let root: Root | null = null
let container: HTMLDivElement | null = null
describe('OnboardingInlineCommandTerminal command-finished forwarding', () => {
beforeEach(() => {
mocks.createTab.mockClear()
mocks.closeTab.mockClear()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
app: {
getFloatingTerminalCwd: vi.fn(async () => '/tmp')
}
}
})
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
root = null
container?.remove()
container = null
Reflect.deleteProperty(window, 'api')
})
it('forwards exit codes only for its own branded worktree id', async () => {
const onCommandFinished = vi.fn()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<OnboardingInlineCommandTerminal
command="npx skills add --skill orchestration --global"
title="Skill setup"
ariaLabel="Skill setup terminal"
worktreeId="settings-orchestration-skill-terminal"
onCommandFinished={onCommandFinished}
/>
)
})
await act(async () => {})
await act(async () => {
dispatchCommandFinished('some-other-worktree', 1)
})
expect(onCommandFinished).not.toHaveBeenCalled()
// Why unbranded-miss matters: pty-connection dispatches the BRANDED id;
// matching the raw panel id would silently never fire in production.
await act(async () => {
dispatchCommandFinished('settings-orchestration-skill-terminal', 1)
})
expect(onCommandFinished).not.toHaveBeenCalled()
await act(async () => {
dispatchCommandFinished('ephemeral-setup-terminal:settings-orchestration-skill-terminal', 1)
})
expect(onCommandFinished).toHaveBeenCalledWith(1)
await act(async () => {
dispatchCommandFinished(
'ephemeral-setup-terminal:settings-orchestration-skill-terminal',
null
)
})
expect(onCommandFinished).toHaveBeenLastCalledWith(null)
})
})
@@ -3,6 +3,10 @@ import { Loader2 } from 'lucide-react'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { PASTE_TERMINAL_TEXT_EVENT, type PasteTerminalTextDetail } from '@/constants/terminal'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
ORCA_TERMINAL_COMMAND_FINISHED_EVENT,
type TerminalCommandFinishedEventDetail
} from '@/hooks/terminal-command-finished-event'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { brandEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
@@ -29,6 +33,8 @@ type OnboardingInlineCommandTerminalProps = {
onOpened?: () => void
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => void
onTerminalExit?: () => void
// OSC 133;D reports the command outcome while the shell remains alive.
onCommandFinished?: (bestEffortExitCode: number | null) => void
}
/**
@@ -48,7 +54,8 @@ export function OnboardingInlineCommandTerminal({
shellOverride,
onOpened,
onInteracted,
onTerminalExit
onTerminalExit,
onCommandFinished
}: OnboardingInlineCommandTerminalProps): React.JSX.Element {
// Why: brand the id so a remote runtime scopes this ephemeral terminal to the
// floating terminal instead of rejecting the synthetic id.
@@ -80,6 +87,24 @@ export function OnboardingInlineCommandTerminal({
onOpened?.()
}, [onOpened])
// Why: the branded id isolates command outcomes to this inline terminal.
useEffect(() => {
if (!onCommandFinished) {
return
}
const handleCommandFinished = (event: Event): void => {
const detail = (event as CustomEvent<TerminalCommandFinishedEventDetail>).detail
if (detail?.worktreeId !== worktreeId) {
return
}
onCommandFinished(detail.exitCode)
}
window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished)
return () => {
window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished)
}
}, [onCommandFinished, worktreeId])
useEffect(() => {
let cancelled = false
void window.api.app.getFloatingTerminalCwd({ path: '~' }).then((nextCwd) => {
@@ -0,0 +1,18 @@
import { translate } from '@/i18n/i18n'
export function AgentSkillSetupFailureNotice(props: {
exitCode: number | null
}): React.JSX.Element | null {
if (props.exitCode === null) {
return null
}
return (
<p className="mt-2 text-[12px] leading-snug text-destructive">
{translate(
'auto.components.settings.AgentSkillSetupPanel.setupCommandFailed',
'The setup command exited with code {{value0}}. This error will clear after a successful retry.',
{ value0: props.exitCode }
)}
</p>
)
}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, type ComponentProps } from 'react'
import { act, useState, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,11 +12,17 @@ const UPDATE_COMMAND = 'npx skills update orca-cli --global'
const mocks = vi.hoisted(() => ({
clipboardWrite: vi.fn(),
terminalProps: [] as { command: string; description: string }[],
terminalProps: [] as {
command: string
description: string
onTerminalExit?: () => void
onCommandFinished?: (bestEffortExitCode: number | null) => void
}[],
toastError: vi.fn(),
toastSuccess: vi.fn(),
skillsChanged: vi.fn(),
skillsRefreshed: vi.fn()
skillsRefreshed: vi.fn(),
terminalInstanceCount: 0
}))
vi.mock('sonner', () => ({
@@ -32,13 +38,23 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
}))
vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({
OnboardingInlineCommandTerminal: (props: { command: string; description: string }) => {
OnboardingInlineCommandTerminal: (props: {
command: string
description: string
onTerminalExit?: () => void
onCommandFinished?: (bestEffortExitCode: number | null) => void
}) => {
const [instance] = useState(() => {
mocks.terminalInstanceCount += 1
return mocks.terminalInstanceCount
})
mocks.terminalProps.push(props)
return (
<div
data-testid="inline-command-terminal"
data-command={props.command}
data-description={props.description}
data-instance={instance}
>
{props.command}
</div>
@@ -139,6 +155,7 @@ describe('AgentSkillSetupPanel', () => {
mocks.toastSuccess.mockReset()
mocks.skillsChanged.mockReset()
mocks.skillsRefreshed.mockReset()
mocks.terminalInstanceCount = 0
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -354,4 +371,167 @@ describe('AgentSkillSetupPanel', () => {
expect(container?.textContent).toContain(INSTALL_COMMAND)
expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND })
})
it('keeps a failed setup command visible with durable recovery', async () => {
const onRecheck = vi.fn()
await renderInteractivePanel({ onRecheck })
await clickButton('Install')
onRecheck.mockClear()
await act(async () => {
const onCommandFinished = mocks.terminalProps.at(-1)?.onCommandFinished
onCommandFinished?.(1)
onCommandFinished?.(0)
})
expect(container?.textContent).toContain(
'The setup command exited with code 1. This error will clear after a successful retry.'
)
expect(container?.textContent).toContain('Setup failed')
expect(container?.querySelector('[data-testid="inline-command-terminal"]')).not.toBeNull()
expect(findButton('Retry').disabled).toBe(false)
expect(onRecheck).toHaveBeenCalledTimes(1)
})
it('clears the failure notice when a later command succeeds', async () => {
await renderInteractivePanel()
await clickButton('Install')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(1)
})
await clickButton('Retry')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(0)
})
expect(container?.textContent).not.toContain('exited with code')
})
it('keeps the failure verdict when a command finishes without an exit code', async () => {
await renderInteractivePanel()
await clickButton('Install')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(1)
})
await clickButton('Retry')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(null)
})
expect(container?.textContent).toContain(
'The setup command exited with code 1. This error will clear after a successful retry.'
)
})
it('retries a failed command in a fresh interactive terminal', async () => {
let finishRetryPreflight: (() => void) | null = null
const retryPreflight = new Promise<void>((resolve) => {
finishRetryPreflight = resolve
})
let preflightCount = 0
await renderInteractivePanel({
onBeforeOpenTerminal: () => {
preflightCount += 1
return preflightCount === 1 ? undefined : retryPreflight
}
})
await clickButton('Install')
const firstInstance = container
?.querySelector('[data-testid="inline-command-terminal"]')
?.getAttribute('data-instance')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(1)
})
await clickButton('Retry')
expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull()
await act(async () => {
finishRetryPreflight?.()
await retryPreflight
})
await act(async () => {})
expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND })
expect(
container
?.querySelector('[data-testid="inline-command-terminal"]')
?.getAttribute('data-instance')
).not.toBe(firstInstance)
expect(findButton('Retry').disabled).toBe(true)
})
it('keeps the command failure authoritative over presence discovery', async () => {
await renderInteractivePanel({ freshnessSkillName: 'orca-cli' })
await clickButton('Install')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(1)
})
await rerenderInteractivePanel({ installed: true, freshnessSkillName: 'orca-cli' })
expect(container?.textContent).toContain('Setup failed')
expect(container?.textContent).toContain('exited with code 1')
expect(container?.textContent).not.toContain('Installed')
expect(container?.querySelector('[data-testid="skill-freshness"]')).toBeNull()
expect(findButton('Retry').disabled).toBe(false)
})
it('keeps failed updates recoverable when installed re-check is hidden', async () => {
await renderInteractivePanel({
installed: true,
installedCommand: UPDATE_COMMAND,
showRecheckWhenInstalled: false
})
await clickButton('Update')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(1)
})
expect(container?.textContent).toContain('Setup failed')
expect(container?.textContent).toContain('exited with code 1')
expect(findButton('Retry').disabled).toBe(false)
await clickButton('Retry')
expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND })
})
it('invalidates shared skill state before the direct completion re-check', async () => {
const calls: string[] = []
mocks.skillsChanged.mockImplementation(() => calls.push('invalidate'))
const onRecheck = vi.fn(() => {
calls.push('recheck')
})
await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck })
await clickButton('Install')
calls.length = 0
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(0)
})
expect(calls).toEqual(['invalidate', 'recheck'])
})
it('re-enables Install after the setup shell exits so a failed attempt can retry', async () => {
await renderInteractivePanel()
await clickButton('Install')
expect(findButton('Install').disabled).toBe(true)
await act(async () => {
mocks.terminalProps.at(-1)?.onTerminalExit?.()
})
expect(findButton('Install').disabled).toBe(false)
expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull()
await clickButton('Install')
expect(findButton('Install').disabled).toBe(true)
expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND })
})
})
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { IntegrationStatusPill } from '../integration-status-pill'
import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice'
import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import {
@@ -15,51 +17,6 @@ import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
type AgentSkillSetupPanelVariant = 'card' | 'inline'
type SkillPrerequisiteStatus = Awaited<ReturnType<typeof window.api.cli.getInstallStatus>>
type AgentSkillSetupPanelProps = {
title: string
description: ReactNode
command: string
installedCommand?: string
terminalTitle: string
terminalAriaLabel: string
terminalWorktreeId: string
installed: boolean
loading: boolean
error: string | null
installDisabled?: boolean
terminalHeightPx?: number
terminalShellOverride?: string
leading?: ReactNode
icon?: ReactNode
variant?: AgentSkillSetupPanelVariant
className?: string
// Why: when an enclosing surface (e.g. a modal) already shows the title and
// status, hide the panel's own header row to avoid a duplicate heading.
hideHeader?: boolean
preInstallNotice?: ReactNode
getPrerequisiteStatus?: () => Promise<SkillPrerequisiteStatus>
isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean
onBeforeOpenTerminal?: () => void | Promise<void>
showInstallWhenInstalled?: boolean
showRecheckWhenInstalled?: boolean
installLabel?: string
installedInstallLabel?: string
// Why: defaults to 'outline' so settings panels stay unchanged; modals that make
// Install the sole footer CTA pass 'default' for a filled primary hierarchy.
installVariant?: ComponentProps<typeof Button>['variant']
actionHint?: ReactNode
openingHint?: ReactNode
footer?: ReactNode
onRecheck: () => void | Promise<unknown>
// Why: when set, the installed pill reflects skill freshness and Re-check also
// refreshes the freshness inventory. Callers omit it for non-local runtimes,
// which the local-host-only freshness scan cannot vouch for.
freshnessSkillName?: string
}
export function AgentSkillSetupPanel({
title,
description,
@@ -102,7 +59,11 @@ export function AgentSkillSetupPanel({
translate('auto.components.settings.AgentSkillSetupPanel.updateLabel', 'Update')
const [terminalOpen, setTerminalOpen] = useState(false)
const [terminalCommand, setTerminalCommand] = useState<string | null>(null)
const [terminalAttempt, setTerminalAttempt] = useState(0)
const [terminalOpening, setTerminalOpening] = useState(false)
const [setupAttemptRunning, setSetupAttemptRunning] = useState(false)
const [setupCommandFailedCode, setSetupCommandFailedCode] = useState<number | null>(null)
const setupAttemptRunningRef = useRef(false)
const [preInstallNoticeVisible, setPreInstallNoticeVisible] = useState(
Boolean(preInstallNotice && !installed)
)
@@ -116,6 +77,68 @@ export function AgentSkillSetupPanel({
// already-open terminal pinned to the command selected by the user's click.
const openTerminalCommand = terminalCommand ?? activeCommand
const openSetupTerminal = (): void => {
if (terminalOpening || setupAttemptRunning) {
return
}
const nextCommand =
setupCommandFailedCode !== null && terminalCommand ? terminalCommand : activeCommand
setTerminalOpening(true)
if (setupCommandFailedCode !== null) {
setTerminalOpen(false)
}
void (async () => {
let shouldOpenTerminal = false
try {
await onBeforeOpenTerminal?.()
await refreshPreInstallNotice()
shouldOpenTerminal = true
} catch {
shouldOpenTerminal = false
} finally {
if (mountedRef.current) {
setTerminalOpening(false)
if (shouldOpenTerminal) {
setTerminalCommand(nextCommand)
setTerminalAttempt((attempt) => attempt + 1)
setTerminalOpen(true)
setupAttemptRunningRef.current = true
setSetupAttemptRunning(true)
}
}
}
})()
}
// Why: PTY exit is the shell's status; OSC 133;D reports the install command.
const handleSetupCommandFinished = useCallback(
(bestEffortExitCode: number | null): void => {
// Nested shells can emit duplicate completion markers in one PTY chunk.
if (!setupAttemptRunningRef.current) {
return
}
setupAttemptRunningRef.current = false
setSetupAttemptRunning(false)
if (bestEffortExitCode !== null) {
setSetupCommandFailedCode(bestEffortExitCode === 0 ? null : bestEffortExitCode)
}
if (freshnessSkillName) {
notifyInstalledAgentSkillsChanged()
}
void onRecheck()
},
[freshnessSkillName, onRecheck]
)
const handleTerminalExit = useCallback((): void => {
if (mountedRef.current) {
setupAttemptRunningRef.current = false
setTerminalOpen(false)
setSetupAttemptRunning(false)
}
notifyInstalledAgentSkillsChanged()
}, [mountedRef])
useEffect(() => {
if (!preInstallNotice) {
setPreInstallNoticeVisible(false)
@@ -180,36 +203,12 @@ export function AgentSkillSetupPanel({
const actionRow = (
<div className="mt-3 flex flex-wrap items-center gap-2">
{!installed || showInstallWhenInstalled ? (
{(!installed || showInstallWhenInstalled) && setupCommandFailedCode === null ? (
<Button
type="button"
variant={installVariant}
size="sm"
onClick={() => {
if (terminalOpening) {
return
}
const nextCommand = activeCommand
setTerminalOpening(true)
void (async () => {
let shouldOpenTerminal = false
try {
await onBeforeOpenTerminal?.()
await refreshPreInstallNotice()
shouldOpenTerminal = true
} catch {
shouldOpenTerminal = false
} finally {
if (mountedRef.current) {
setTerminalOpening(false)
if (shouldOpenTerminal) {
setTerminalCommand(nextCommand)
setTerminalOpen(true)
}
}
}
})()
}}
onClick={openSetupTerminal}
disabled={terminalOpen || installDisabled || terminalOpening}
>
{terminalOpening ? (
@@ -224,22 +223,32 @@ export function AgentSkillSetupPanel({
: resolvedInstallLabel}
</Button>
) : null}
{!installed || showRecheckWhenInstalled ? (
{setupCommandFailedCode !== null || !installed || showRecheckWhenInstalled ? (
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => {
if (setupCommandFailedCode !== null) {
openSetupTerminal()
return
}
void Promise.resolve(onRecheck()).then(() => {
// Reuse the completed scan so sibling surfaces sync without rediscovery.
notifyInstalledAgentSkillsRefreshed()
})
}}
disabled={loading}
disabled={
setupCommandFailedCode !== null
? installDisabled || terminalOpening || setupAttemptRunning
: loading
}
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
{translate('auto.components.settings.AgentSkillSetupPanel.c689392435', 'Re-check')}
<RefreshCw className={cn('size-3.5', (loading || terminalOpening) && 'animate-spin')} />
{setupCommandFailedCode !== null
? translate('auto.components.settings.AgentSkillSetupPanel.retrySetup', 'Retry')
: translate('auto.components.settings.AgentSkillSetupPanel.c689392435', 'Re-check')}
</Button>
) : null}
{terminalOpening ? (
@@ -269,7 +278,7 @@ export function AgentSkillSetupPanel({
<>
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
{/* hideHeader drops the title row; keep freshness so guided hubs still surface updates. */}
{installed && freshnessSkillName ? (
{installed && freshnessSkillName && setupCommandFailedCode === null ? (
<div className="mb-2">
<SkillFreshnessStatusPill skillName={freshnessSkillName} />
</div>
@@ -286,7 +295,14 @@ export function AgentSkillSetupPanel({
<div className="min-w-0 flex-1 self-center">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="text-[15px] font-semibold leading-tight text-foreground">{title}</h3>
{loading && !installed ? (
{setupCommandFailedCode !== null ? (
<IntegrationStatusPill tone="attention">
{translate(
'auto.components.settings.AgentSkillSetupPanel.setupFailed',
'Setup failed'
)}
</IntegrationStatusPill>
) : loading && !installed ? (
<IntegrationStatusPill tone="neutral">
{translate(
'auto.components.settings.AgentSkillSetupPanel.68a468752e',
@@ -322,6 +338,7 @@ export function AgentSkillSetupPanel({
<p className="text-[13px] leading-snug text-muted-foreground">{description}</p>
) : null}
{actionRow}
<AgentSkillSetupFailureNotice exitCode={setupCommandFailedCode} />
{actionHint ? <div className="mt-2">{actionHint}</div> : null}
{!installed && preInstallNotice && preInstallNoticeVisible ? (
<p className="mt-3 text-[12px] leading-snug text-muted-foreground">
@@ -373,6 +390,7 @@ export function AgentSkillSetupPanel({
</Tooltip>
</div>
<OnboardingInlineCommandTerminal
key={terminalAttempt}
worktreeId={terminalWorktreeId}
command={openTerminalCommand}
title={terminalTitle}
@@ -386,7 +404,8 @@ export function AgentSkillSetupPanel({
terminalTopMarginPx={8}
descriptionPaddingClassName="px-4 py-2"
autoScrollIntoView={false}
onTerminalExit={notifyInstalledAgentSkillsChanged}
onTerminalExit={handleTerminalExit}
onCommandFinished={handleSetupCommandFinished}
/>
</div>
) : null}
@@ -0,0 +1,43 @@
import type { ComponentProps, ReactNode } from 'react'
import type { Button } from '../ui/button'
type AgentSkillSetupPanelVariant = 'card' | 'inline'
type SkillPrerequisiteStatus = Awaited<ReturnType<typeof window.api.cli.getInstallStatus>>
export type AgentSkillSetupPanelProps = {
title: string
description: ReactNode
command: string
installedCommand?: string
terminalTitle: string
terminalAriaLabel: string
terminalWorktreeId: string
installed: boolean
loading: boolean
error: string | null
installDisabled?: boolean
terminalHeightPx?: number
terminalShellOverride?: string
leading?: ReactNode
icon?: ReactNode
variant?: AgentSkillSetupPanelVariant
className?: string
// Enclosing modals can own the title and status.
hideHeader?: boolean
preInstallNotice?: ReactNode
getPrerequisiteStatus?: () => Promise<SkillPrerequisiteStatus>
isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean
onBeforeOpenTerminal?: () => void | Promise<void>
showInstallWhenInstalled?: boolean
showRecheckWhenInstalled?: boolean
installLabel?: string
installedInstallLabel?: string
// Modal footers can promote Install to the primary action.
installVariant?: ComponentProps<typeof Button>['variant']
actionHint?: ReactNode
openingHint?: ReactNode
footer?: ReactNode
onRecheck: () => void | Promise<unknown>
// Freshness inventory is local-host-only.
freshnessSkillName?: string
}
@@ -271,7 +271,7 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
ssh.dispose()
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2)
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID)
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID, 0)
})
it('drops a same-turn status row on command finished for SSH PTYs only', async () => {
@@ -126,13 +126,13 @@ export function createParkedTerminalCommandStatusPolicy(options: {
)
return {
onCommandFinished: (): void => {
onCommandFinished: (bestEffortExitCode: number | null): void => {
if (disposed) {
return
}
// Why: the finished command may have moved HEAD or the index (an agent running
// `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(worktreeId)
dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode)
// Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted
// path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need
// pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a
@@ -2095,13 +2095,13 @@ export function connectPanePty(
// (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact —
// routing both through this handler keeps the drop/interrupt semantics
// identical across authority modes.
const handleCommandFinished = (_bestEffortExitCode: number | null): void => {
const handleCommandFinished = (bestEffortExitCode: number | null): void => {
clearCommandInferredPaneAgentAfterPtySideEffects()
visibleForegroundSamplePending = false
const shouldDeferStatusDrop = paneForegroundAgentTracker.onCommandFinished()
// Why: the finished command may have moved HEAD or the index (e.g.
// `git checkout`); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
dispatchTerminalCommandFinishedEvent(deps.worktreeId, bestEffortExitCode)
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const inferenceResult = flushPendingInterruptInference()
@@ -2,12 +2,16 @@ export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finis
export type TerminalCommandFinishedEventDetail = {
worktreeId: string
// OSC 133;D may omit the command's exit code.
exitCode: number | null
}
// Why: the OSC 133;D handler lives in a per-pane closure; a window event lets
// decoupled consumers (e.g. git status refresh) react to shell commands
// finishing without reaching into terminal internals.
export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void {
// decoupled consumers react without reaching into terminal internals.
export function dispatchTerminalCommandFinishedEvent(
worktreeId: string,
exitCode: number | null
): void {
// Why: unit tests and non-DOM renderer shims may expose only the preload API.
if (typeof window.dispatchEvent !== 'function') {
return
@@ -15,7 +19,7 @@ export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void {
window.dispatchEvent(
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
detail: { worktreeId }
detail: { worktreeId, exitCode }
})
)
}
+4 -1
View File
@@ -5319,7 +5319,10 @@
"5f818f12ab": "Preparing...",
"4c05b9d7cb": "Preparing setup terminal.",
"installLabel": "Install",
"updateLabel": "Update"
"updateLabel": "Update",
"setupCommandFailed": "The setup command exited with code {{value0}}. This error will clear after a successful retry.",
"setupFailed": "Setup failed",
"retrySetup": "Retry"
},
"AgentsPane": {
"d83834f5e6": "Detecting installed agents…",