mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
+117
@@ -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 }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
Reference in New Issue
Block a user