From 73e243705f508ec1bf422ca41325298f55b5b1aa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:31:00 -0700 Subject: [PATCH 1/3] fix: stabilize new worktree task source tabs (#11701) --- .../SmartWorkspaceNameField-source-boundaries.test.ts | 4 ++++ .../components/new-workspace/SmartWorkspaceNameField.tsx | 9 ++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index bfe556addf4..414b438bad7 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -113,4 +113,8 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(FIELD_SOURCE).toContain('onPointerDown={() => {') expect(FIELD_SOURCE).toContain('markSourcePopoverUserEngaged()') }) + + it('confines source-mode overflow to the source strip', () => { + expect(FIELD_SOURCE).toContain('overflow-x-auto overflow-y-hidden px-0 scrollbar-sleek') + }) }) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index a0168f90093..ead339690ea 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -394,11 +394,10 @@ export default function SmartWorkspaceNameField({ link: NonNullable> matchingRepo: RepoOption | null } | null>(null) - // Why: Jira status is read lazily — mounting the composer must not cost an IPC/RPC round-trip, - // so only reach for it once the user opens the source picker, picks Jira, or pastes an issue URL. + // Why: read Jira status when the composer mounts so an already-configured source is available + // before users start typing, without showing Jira for hosts where it is not configured. const jiraConnection = useJiraSourceConnection({ - enabled: - !disabled && !textOnly && (open || mode === 'jira' || isBlockingJiraUrlIntent(mode, value)), + enabled: !disabled && !textOnly && jiraSourceContext !== null, sourceContext: jiraSourceContext }) const jiraConnectionStatus = jiraConnection.status @@ -1624,7 +1623,7 @@ export default function SmartWorkspaceNameField({ { // Why: Radix Tabs roving focus re-applies tabindex=0 to the active trigger (races React commits), so forward Tab to the input. const previous = event.relatedTarget as HTMLElement | null From e1092291ae4e90d18b1d896e9f8e8c3ef056480f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:41:51 -0700 Subject: [PATCH 2/3] fix(settings): keep copied skill commands bare for POSIX-family Windows shells (#11599) The cmd.exe npx preflight added in #10453 silently no-ops when pasted into Git Bash: MSYS rewrites the leading /d /s /c switches into drive paths, so cmd.exe starts an interactive session and never runs the payload. Skip the wrapper when the configured Windows shell resolves to the posix family, matching the shell the copied command actually lands in. --- .../settings/CliSkillRuntimeSetup.test.tsx | 49 +++++++++++++++++++ .../settings/CliSkillRuntimeSetup.tsx | 12 +++++ 2 files changed, 61 insertions(+) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index 6150bd808eb..269074ff895 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -243,6 +243,55 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { } }) + it('skips the Windows preflight when the configured Windows shell is POSIX-family', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const windowsHost = { runtime: 'host', label: 'Windows' } as const + const previous = useAppStore.getState() + + try { + // MSYS rewrites cmd.exe's leading /d /s /c switches into drive paths, so + // the copied command must stay bare for a Git Bash / wsl.exe paste target. + for (const terminalWindowsShell of ['git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe']) { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell } + }) + expect(buildSkillCommandForRuntime(installCommand, windowsHost, 'win32')).toBe( + installCommand + ) + } + + // cmd-family shells still need the preflight wrapper. + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'cmd.exe' } + }) + expect(buildSkillCommandForRuntime(installCommand, windowsHost, 'win32')).toBe( + `${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"` + ) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + + it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const previous = useAppStore.getState() + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'git-bash' } + }) + + try { + expect( + buildSkillCommandForRuntime( + 'npx skills update orchestration --global', + { runtime: 'host', label: 'Windows' }, + 'win32' + ) + ).toBe(installCommand) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + it('does not wrap unrelated Windows host commands', () => { expect( buildSkillCommandForRuntime( diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index bee39049fc5..005b5dfc5c6 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -8,6 +8,7 @@ import { quotePowerShellNativeArgument } from '../../../../shared/powershell-native-argument' import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' +import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime' import { useAppStore } from '@/store' import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands' @@ -136,6 +137,10 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( // Why: skill setup terminals spawn on the focused runtime environment, so a // Windows client must not hand a cmd.exe command to a remote host. isRemoteRuntimeEnvironmentFocused() || + // Why: the copied command lands in the user's configured shell, and MSYS + // shells rewrite cmd.exe's leading /d /s /c switches into drive paths, + // starting an interactive cmd session instead of running the payload. + isPosixFamilyWindowsShellConfigured() || !/^npx\s+skills\s+(?:add|update)\b/i.test(trimmedCommand) ) { return command @@ -149,6 +154,13 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( return `cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (${missingNpxGuidance}) else (${trimmedCommand})"` } +function isPosixFamilyWindowsShellConfigured(): boolean { + return ( + resolveWindowsShellStartupFamily(useAppStore.getState().settings?.terminalWindowsShell) === + 'posix' + ) +} + function isRemoteRuntimeEnvironmentFocused(): boolean { // Why: the terminal router also weighs how many environments are saved, but // that slice has no subscriber here. Read only the focused id, which every From 0ffdb79d343be82f17761d5f8a5ee90093393328 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:44:49 -0700 Subject: [PATCH 3/3] 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 --- ...eCommandTerminal.command-finished.test.tsx | 117 +++++++++++ .../OnboardingInlineCommandTerminal.tsx | 27 ++- .../settings/AgentSkillSetupFailureNotice.tsx | 18 ++ .../settings/AgentSkillSetupPanel.test.tsx | 188 +++++++++++++++++- .../settings/AgentSkillSetupPanel.tsx | 177 +++++++++-------- .../settings/agent-skill-setup-panel-props.ts | 43 ++++ .../parked-terminal-command-status.test.ts | 2 +- .../parked-terminal-command-status.ts | 4 +- .../terminal-pane/pty-connection.ts | 4 +- .../hooks/terminal-command-finished-event.ts | 12 +- src/renderer/src/i18n/locales/en.json | 5 +- 11 files changed, 503 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx create mode 100644 src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx create mode 100644 src/renderer/src/components/settings/agent-skill-setup-panel-props.ts diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx new file mode 100644 index 00000000000..1b55391dec4 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx @@ -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) => unknown) => + selector({ + createTab: mocks.createTab, + closeTab: mocks.closeTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabCustomTitle: mocks.setTabCustomTitle + }) +})) + +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () =>
+})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: vi.fn() +})) + +function dispatchCommandFinished(worktreeId: string, exitCode: number | null): void { + window.dispatchEvent( + new CustomEvent(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( + + ) + }) + 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) + }) +}) diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index a0b8e894b47..0a7f0ae7a8f 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -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) => 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).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) => { diff --git a/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx new file mode 100644 index 00000000000..eba25d637a4 --- /dev/null +++ b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx @@ -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 ( +

+ {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupCommandFailed', + 'The setup command exited with code {{value0}}. This error will clear after a successful retry.', + { value0: props.exitCode } + )} +

+ ) +} diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 533144f8bdf..ac44ed3285c 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -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 (
{props.command}
@@ -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((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 }) + }) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 96bf0af5a8a..c141b9bf472 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -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> - -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 - isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean - onBeforeOpenTerminal?: () => void | Promise - 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['variant'] - actionHint?: ReactNode - openingHint?: ReactNode - footer?: ReactNode - onRecheck: () => void | Promise - // 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(null) + const [terminalAttempt, setTerminalAttempt] = useState(0) const [terminalOpening, setTerminalOpening] = useState(false) + const [setupAttemptRunning, setSetupAttemptRunning] = useState(false) + const [setupCommandFailedCode, setSetupCommandFailedCode] = useState(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 = (
- {!installed || showInstallWhenInstalled ? ( + {(!installed || showInstallWhenInstalled) && setupCommandFailedCode === null ? ( ) : null} - {!installed || showRecheckWhenInstalled ? ( + {setupCommandFailedCode !== null || !installed || showRecheckWhenInstalled ? ( ) : null} {terminalOpening ? ( @@ -269,7 +278,7 @@ export function AgentSkillSetupPanel({ <> {error ?

{error}

: null} {/* hideHeader drops the title row; keep freshness so guided hubs still surface updates. */} - {installed && freshnessSkillName ? ( + {installed && freshnessSkillName && setupCommandFailedCode === null ? (
@@ -286,7 +295,14 @@ export function AgentSkillSetupPanel({

{title}

- {loading && !installed ? ( + {setupCommandFailedCode !== null ? ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupFailed', + 'Setup failed' + )} + + ) : loading && !installed ? ( {translate( 'auto.components.settings.AgentSkillSetupPanel.68a468752e', @@ -322,6 +338,7 @@ export function AgentSkillSetupPanel({

{description}

) : null} {actionRow} + {actionHint ?
{actionHint}
: null} {!installed && preInstallNotice && preInstallNoticeVisible ? (

@@ -373,6 +390,7 @@ export function AgentSkillSetupPanel({

) : null} diff --git a/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts new file mode 100644 index 00000000000..9596d47d911 --- /dev/null +++ b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts @@ -0,0 +1,43 @@ +import type { ComponentProps, ReactNode } from 'react' +import type { Button } from '../ui/button' + +type AgentSkillSetupPanelVariant = 'card' | 'inline' +type SkillPrerequisiteStatus = Awaited> + +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 + isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean + onBeforeOpenTerminal?: () => void | Promise + showInstallWhenInstalled?: boolean + showRecheckWhenInstalled?: boolean + installLabel?: string + installedInstallLabel?: string + // Modal footers can promote Install to the primary action. + installVariant?: ComponentProps['variant'] + actionHint?: ReactNode + openingHint?: ReactNode + footer?: ReactNode + onRecheck: () => void | Promise + // Freshness inventory is local-host-only. + freshnessSkillName?: string +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index c2479267e24..24991e01914 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index ff422e55de5..781625318bd 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49c11f55cb3..f4a4da3b9ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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() diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 5a304b41846..2bdd0e4b666 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -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(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId } + detail: { worktreeId, exitCode } }) ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9e1cd986862..76557b35550 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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…",