diff --git a/src/renderer/src/components/run-actions/RunActionButton.tsx b/src/renderer/src/components/run-actions/RunActionButton.tsx index e803e5c5e41..9183a7c3afe 100644 --- a/src/renderer/src/components/run-actions/RunActionButton.tsx +++ b/src/renderer/src/components/run-actions/RunActionButton.tsx @@ -10,6 +10,7 @@ import { type WorktreeRunActions } from '@/lib/run-actions/run-action-config' import { startRunAction, stopRunAction } from '@/lib/run-actions/run-action-controller' +import { autoRunProvisionedWorkspaceOnce } from '@/lib/run-actions/run-action-events' import { useWorktreeRunActionRuns, type RunActionRun } from '@/lib/run-actions/run-actions-store' import type { WorkspacePort } from '../../../../shared/workspace-ports' import { RunActionButtonView } from './RunActionButtonView' @@ -79,6 +80,11 @@ export function RunActionButton({ worktreeId }: { worktreeId: string }): React.J // eslint-disable-next-line react-hooks/exhaustive-deps [config, worktreeId, runs] ) + useEffect(() => { + if (config?.setupProvisioned) { + void autoRunProvisionedWorkspaceOnce(worktreeId, config) + } + }, [config, worktreeId]) if (!config || !primary) { return null } diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts index 06d1e7b42dd..6e526a0edc0 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts @@ -1,4 +1,5 @@ /** Keep store-level command status while parked; foreground confirmation and input-intent inference remain with mounted panes. */ +import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' import { resolvePaneAgentOwner } from '../../../../../../shared/agent-status/identity/pane-agent-owner' import { parseAppSshPtyId } from '../../../../../../shared/ssh-pty-id' @@ -173,7 +174,12 @@ export function createParkedTerminalCommandStatusPolicy(options: { return } // Commands can change HEAD/index; nudge git UI immediately rather than wait for polling. - dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode, tabId) + dispatchTerminalCommandFinishedEvent( + worktreeId, + bestEffortExitCode, + tabId, + parsePaneKey(paneKey)?.leafId + ) void retireForegroundAgentUnlessConfirmed() // Mirror SSH’s unprobed same-turn drop; local PTYs require mounted foreground confirmation against nested-shell 133;D. if (parseAppSshPtyId(ptyId) === null) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts index b718f5f481f..8844dc92092 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts @@ -1,3 +1,4 @@ +import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import { agentTypeToIconAgent } from '../../../../../../shared/agent-status/presentation/agent-type-icon-agent' import { dismissRetainedProgramResult } from '@/lib/program-status/result-retention' import { isRetainedProgramResult } from '@/lib/program-status/row' @@ -217,7 +218,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { dispatchTerminalCommandFinishedEvent( session.deps.worktreeId, bestEffortExitCode, - session.deps.tabId + session.deps.tabId, + parsePaneKey(session.cacheKey)?.leafId ) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[session.cacheKey] diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 28abe1726df..5b965ee4d80 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -6,13 +6,16 @@ export type TerminalCommandFinishedEventDetail = { exitCode: number | null /** The pane's tab, when the emitter knows it; consumers tracking one tab's command need it. */ tabId?: string + /** The pane's stable leaf: a split's commands are not the tab's first pane's. */ + leafId?: string } // Window events let decoupled consumers observe per-pane OSC completion without accessing terminal internals. export function dispatchTerminalCommandFinishedEvent( worktreeId: string, exitCode: number | null, - tabId?: string + tabId?: string, + leafId?: string ): void { // Why: unit tests and non-DOM renderer shims may expose only the preload API. if (typeof window.dispatchEvent !== 'function') { @@ -21,7 +24,7 @@ export function dispatchTerminalCommandFinishedEvent( window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}) } + detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}) } }) ) } diff --git a/src/renderer/src/lib/run-actions/run-action-config.ts b/src/renderer/src/lib/run-actions/run-action-config.ts index be7d6b2ad80..0ac4ee3ef3f 100644 --- a/src/renderer/src/lib/run-actions/run-action-config.ts +++ b/src/renderer/src/lib/run-actions/run-action-config.ts @@ -21,6 +21,8 @@ export type WorktreeRunActions = { scripts: OrcaRunScripts /** Ids defined by the shared orca.yaml (they need the user's approval before running). */ sharedIds: ReadonlySet + /** The host prepared the checkout itself (setup ran in an environment build, not a Setup tab). */ + setupProvisioned?: true } export type RunActionTarget = { @@ -96,13 +98,20 @@ export async function loadWorktreeRunActions( } const repo = state.repos.find((candidate) => candidate.id === target.repoId) let shared: OrcaHooks['scripts'] | undefined + let setupProvisioned = false try { const result = await checkRuntimeHooks(target.hostId, target.repoId, options) shared = result.status === 'error' ? undefined : (result.hooks?.scripts ?? undefined) + setupProvisioned = result.environmentProvisioned === true } catch { shared = undefined } - return mergeRunActions(shared, repo?.hookSettings?.scripts, target.isLocal ? 'local' : 'remote') + const merged = mergeRunActions( + shared, + repo?.hookSettings?.scripts, + target.isLocal ? 'local' : 'remote' + ) + return setupProvisioned ? { ...merged, setupProvisioned: true } : merged } /** The action the Run button and shortcut act on: the last one still running here, else the default. */ diff --git a/src/renderer/src/lib/run-actions/run-action-controller.test.ts b/src/renderer/src/lib/run-actions/run-action-controller.test.ts index 1d1d6d27bba..9f828ebd204 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.test.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.test.ts @@ -10,6 +10,7 @@ type MockState = { { id: string; entityId: string; contentType: string; groupId: string }[] > ptyIdsByTabId: Record + terminalLayoutsByTabId: Record createTab: ReturnType closeTab: ReturnType setTabCustomTitle: ReturnType @@ -69,7 +70,8 @@ vi.stubGlobal('window', new EventTarget()) const { mergeRunActions, loadWorktreeRunActions } = await import('./run-action-config') const { startRunAction, stopRunAction, relaunchInterruptedRunActions, armRunActionRelaunch } = await import('./run-action-controller') -const { installRunActionEvents, armAutoRunAfterSetup } = await import('./run-action-events') +const { installRunActionEvents, armAutoRunAfterSetup, autoRunProvisionedWorkspaceOnce } = + await import('./run-action-events') const { getRunActionRun, resetRunActionRunsForTests } = await import('./run-actions-store') const { dispatchTerminalCommandFinishedEvent } = await import('@/hooks/terminal-command-finished-event') @@ -90,6 +92,7 @@ function makeState(): MockState { tabsByWorktree: {}, unifiedTabsByWorktree: {}, ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, createTab: vi.fn((worktreeId: string) => { const tab = { id: `tab-${++nextTab}` } state.tabsByWorktree[worktreeId] = [...(state.tabsByWorktree[worktreeId] ?? []), tab] @@ -322,6 +325,73 @@ describe('run actions', () => { expect(await startRunAction(WT_B, web, config([web]))).toBe(false) }) + it('a command finishing in a split of the run tab does not end the run', async () => { + await startRunAction(WT_A, web, config([web])) + const { tabId } = getRunActionRun(WT_A, 'web')! + const L1 = '11111111-1111-4111-8111-111111111111' + const L2 = '22222222-2222-4222-8222-222222222222' + const notifyLayouts = (): void => { + for (const listener of mocks.subscribers) { + listener(state, { tabsByWorktree: state.tabsByWorktree, terminalLayoutsByTabId: {} }) + } + } + state.terminalLayoutsByTabId = { [tabId]: { root: { type: 'leaf', leafId: L1 } } } + notifyLayouts() + expect(getRunActionRun(WT_A, 'web')?.leafId).toBe(L1) + state.terminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: L1 }, + second: { type: 'leaf', leafId: L2 } + } + } + } + notifyLayouts() + dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, L2) + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + dispatchTerminalCommandFinishedEvent(WT_A, 1, tabId, L1) + expect(getRunActionRun(WT_A, 'web')).toMatchObject({ status: 'failed', exitCode: 1 }) + }) + + it('ignores a pane it cannot attribute while the tab has several panes', async () => { + await startRunAction(WT_A, web, config([web])) + const { tabId } = getRunActionRun(WT_A, 'web')! + state.terminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + } + } + } + dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, '22222222-2222-4222-8222-222222222222') + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + }) + + it('auto-runs once on first open when the host ran setup in the environment build', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => void storage.set(key, value) + }) + try { + const provisioned = { ...config([web]), setupProvisioned: true as const } + provisioned.scripts = { ...provisioned.scripts, autoRunAfterSetup: true } + expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(true) + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + dispatchTerminalCommandFinishedEvent(WT_A, 0, getRunActionRun(WT_A, 'web')!.tabId) + expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(false) + const notProvisioned = { ...provisioned, setupProvisioned: undefined } + expect(await autoRunProvisionedWorkspaceOnce(WT_B, notProvisioned)).toBe(false) + } finally { + vi.stubGlobal('localStorage', undefined) + } + }) + it('auto-runs the default action once setup succeeds, not when it fails', async () => { mocks.checkRuntimeHooks.mockResolvedValue({ status: 'ok', diff --git a/src/renderer/src/lib/run-actions/run-action-controller.ts b/src/renderer/src/lib/run-actions/run-action-controller.ts index a2dc704c290..d060e038a86 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.ts @@ -21,7 +21,7 @@ import { type RunActionTarget, type WorktreeRunActions } from './run-action-config' -import { findLiveRunTabId, sameRunTab } from './run-action-tabs' +import { findLiveRunTabId, runPanePtyId, sameRunTab } from './run-action-tabs' /** Conductor's grace period: an interrupted dev server gets this long before its shell is closed. */ export const RUN_ACTION_STOP_GRACE_MS = 5_000 @@ -101,8 +101,7 @@ async function stopRunActionOnce(worktreeId: string, actionId: string): Promise< return } const state = useAppStore.getState() - const liveTabId = findLiveRunTabId(state, worktreeId, run.tabId) - const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined + const ptyId = runPanePtyId(state, worktreeId, run.tabId, run.leafId) if (!ptyId) { deleteRunActionRun(worktreeId, actionId) return diff --git a/src/renderer/src/lib/run-actions/run-action-events.ts b/src/renderer/src/lib/run-actions/run-action-events.ts index a29bdf4e3bb..609db852b4b 100644 --- a/src/renderer/src/lib/run-actions/run-action-events.ts +++ b/src/renderer/src/lib/run-actions/run-action-events.ts @@ -13,10 +13,15 @@ import { findRunActionRunByTab, getRunActionRun, listRunActionRuns, - patchRunActionRun + patchRunActionRun, + type RunActionRun } from './run-actions-store' -import { loadWorktreeRunActions, pickPrimaryRunAction } from './run-action-config' -import { findLiveRunTabId, sameRunTab } from './run-action-tabs' +import { + loadWorktreeRunActions, + pickPrimaryRunAction, + type WorktreeRunActions +} from './run-action-config' +import { findLiveRunTabId, runPanePtyId, runTabLeafIds, sameRunTab } from './run-action-tabs' import { isRunActionRelaunchArmed, notifyRunActionStopped, @@ -59,6 +64,10 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void if (!run || (run.status !== 'running' && run.status !== 'stopping')) { return } + if (!isRunPane(run, detail.leafId)) { + // A command finished in a split of the run's tab, not the action itself. + return + } // OSC 133;D may omit the exit code; without one the run is only known to have ended. const status = run.status === 'stopping' @@ -70,6 +79,31 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void notifyRunActionStopped(tabId) } +function isRunPane(run: RunActionRun, leafId: string | undefined): boolean { + if (!leafId) { + return true + } + if (run.leafId) { + return run.leafId === leafId + } + // The run's pane isn't known yet: only a tab with one pane proves which pane finished. + const leaves = runTabLeafIds(useAppStore.getState(), run.worktreeId, run.tabId) + return leaves.length <= 1 +} + +/** A run learns its pane while its tab still has exactly one, before any split exists. */ +function learnRunPanes(state: AppState): void { + for (const run of listRunActionRuns()) { + if (run.leafId) { + continue + } + const leaves = runTabLeafIds(state, run.worktreeId, run.tabId) + if (leaves.length === 1) { + patchRunActionRun(run.worktreeId, run.actionId, { leafId: leaves[0] }) + } + } +} + function pruneClosedRunTabs(state: AppState): void { for (const run of listRunActionRuns()) { // A restarting host rebuilds its tab list; the relaunch owns these runs until it settles. @@ -91,6 +125,42 @@ function pruneClosedRunTabs(state: AppState): void { } } +const AUTO_RAN_KEY = 'orca.runActions.autoRan.v1' + +function readAutoRan(): string[] { + try { + const parsed: unknown = JSON.parse(globalThis.localStorage?.getItem(AUTO_RAN_KEY) ?? '[]') + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +/** + * The host ran setup while building the environment, so no Setup tab will ever finish here: + * honor `autoRunAfterSetup` the first time the workspace is opened, once per workspace. + */ +export async function autoRunProvisionedWorkspaceOnce( + worktreeId: string, + config: WorktreeRunActions +): Promise { + const action = config.setupProvisioned ? pickAutoRunAction(config.scripts) : null + const autoRan = readAutoRan() + if (!action || autoRan.includes(worktreeId) || getRunActionRun(worktreeId, action.id)) { + return false + } + try { + // Recorded before starting, so a second open (or window) can't start it twice. + globalThis.localStorage?.setItem( + AUTO_RAN_KEY, + JSON.stringify([...autoRan, worktreeId].slice(-500)) + ) + } catch { + // Best effort: without storage this still runs once per window session. + } + return startRunAction(worktreeId, action, config, { activate: false }) +} + /** Follows run terminals: their exits update status, and closing one forgets its run. */ export function installRunActionEvents(): () => void { const onFinished = (event: Event): void => { @@ -104,6 +174,9 @@ export function installRunActionEvents(): () => void { let pruneTimer: ReturnType | null = null const installedAt = Date.now() const unsubscribe = useAppStore.subscribe((state, previous) => { + if (state.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId) { + learnRunPanes(state) + } if (state.tabsByWorktree === previous.tabsByWorktree || pruneTimer) { return } @@ -136,8 +209,7 @@ const STARTUP_PRUNE_GRACE_MS = 15_000 async function reconcileLocalRunsAfterStartup(): Promise { const state = useAppStore.getState() for (const run of listRunActionRuns()) { - const liveTabId = findLiveRunTabId(state, run.worktreeId, run.tabId) - const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined + const ptyId = runPanePtyId(state, run.worktreeId, run.tabId, run.leafId) if (run.status !== 'running' || !ptyId || getRemoteRuntimePtyOwner(ptyId)) { continue } diff --git a/src/renderer/src/lib/run-actions/run-action-tabs.ts b/src/renderer/src/lib/run-actions/run-action-tabs.ts index b1b00ee5471..4310b155267 100644 --- a/src/renderer/src/lib/run-actions/run-action-tabs.ts +++ b/src/renderer/src/lib/run-actions/run-action-tabs.ts @@ -1,4 +1,5 @@ import type { AppState } from '@/store/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/workspace-layout/terminal-tab-types' /** A remote host adopts a tab the window created under `web-terminal-`; both name one tab. */ const MIRRORED_TAB_PREFIX = 'web-terminal-' @@ -21,3 +22,43 @@ export function findLiveRunTabId( (state.tabsByWorktree[worktreeId] ?? []).find((tab) => sameRunTab(tab.id, tabId))?.id ?? null ) } + +function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined, out: string[]): string[] { + if (!node) { + return out + } + if (node.type === 'leaf') { + out.push(node.leafId) + } else { + collectLeafIds(node.first, out) + collectLeafIds(node.second, out) + } + return out +} + +/** Every pane of the run's tab, in layout order. */ +export function runTabLeafIds( + state: Pick, + worktreeId: string, + tabId: string +): string[] { + const liveTabId = findLiveRunTabId(state, worktreeId, tabId) + return liveTabId ? collectLeafIds(state.terminalLayoutsByTabId?.[liveTabId]?.root, []) : [] +} + +/** The PTY of the run's own pane, falling back to the tab's first PTY. */ +export function runPanePtyId( + state: Pick, + worktreeId: string, + tabId: string, + leafId: string | undefined +): string | undefined { + const liveTabId = findLiveRunTabId(state, worktreeId, tabId) + if (!liveTabId) { + return undefined + } + const byLeaf = leafId + ? state.terminalLayoutsByTabId?.[liveTabId]?.ptyIdsByLeafId?.[leafId] + : undefined + return byLeaf ?? state.ptyIdsByTabId[liveTabId]?.[0] +} diff --git a/src/renderer/src/lib/run-actions/run-actions-store.ts b/src/renderer/src/lib/run-actions/run-actions-store.ts index 363fb0310d3..803167d10a1 100644 --- a/src/renderer/src/lib/run-actions/run-actions-store.ts +++ b/src/renderer/src/lib/run-actions/run-actions-store.ts @@ -11,6 +11,8 @@ export type RunActionRun = { actionName: string /** The terminal tab showing this run's output. */ tabId: string + /** The tab's pane running the action (learned once the pane exists); splits are other panes. */ + leafId?: string status: RunActionStatus exitCode: number | null startedAt: number @@ -40,7 +42,8 @@ function readRun(value: unknown): RunActionRun | null { return null } const run: Record = { ...value } - const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt } = run + const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt, leafId } = + run if ( typeof worktreeId !== 'string' || typeof repoId !== 'string' || @@ -60,7 +63,8 @@ function readRun(value: unknown): RunActionRun | null { tabId, status: STATUSES.find((known) => known === status) ?? 'exited', exitCode: typeof exitCode === 'number' ? exitCode : null, - startedAt + startedAt, + ...(typeof leafId === 'string' ? { leafId } : {}) } } @@ -124,7 +128,7 @@ export function setRunActionRun(run: RunActionRun): void { export function patchRunActionRun( worktreeId: string, actionId: string, - patch: Partial> + patch: Partial> ): void { const current = runs[worktreeId]?.[actionId] if (!current) {