From ca6401a08fa6c225d1b93af55b9e41549db6affc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:12:21 -0700 Subject: [PATCH] Split automation dispatch event handling (#17155) * Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Fix F3-speech for #17123 * Fix F1-cycle for #17131 --- config/max-lines-baseline.txt | 1 - .../hooks/automation-dispatch-completion.ts | 266 +++++++ .../src/hooks/automation-dispatch-handler.ts | 239 ++++++ .../hooks/automation-dispatch-workspace.ts | 269 +++++++ .../src/hooks/useAutomationDispatchEvents.ts | 695 +----------------- 5 files changed, 776 insertions(+), 694 deletions(-) create mode 100644 src/renderer/src/hooks/automation-dispatch-completion.ts create mode 100644 src/renderer/src/hooks/automation-dispatch-handler.ts create mode 100644 src/renderer/src/hooks/automation-dispatch-workspace.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index fbc4a24ed38..59162f7e001 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -56,7 +56,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx inline src/renderer/src/components/terminal-pane/TerminalPane.tsx inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts -inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts inline src/renderer/src/runtime/runtime-file-client.ts diff --git a/src/renderer/src/hooks/automation-dispatch-completion.ts b/src/renderer/src/hooks/automation-dispatch-completion.ts new file mode 100644 index 00000000000..098d1167a38 --- /dev/null +++ b/src/renderer/src/hooks/automation-dispatch-completion.ts @@ -0,0 +1,266 @@ +import { + createAutomationRunOutputSnapshotBuffer, + selectAutomationRunOutputSnapshot +} from '@/components/automations/automation-run-output-snapshot' +import { useAppStore } from '@/store' +import type { + AutomationDispatchResult, + AutomationPrecheckResult, + AutomationRun +} from '../../../shared/automations-types' +import type { AgentStateHistoryEntry, AgentStatusEntry } from '../../../shared/agent-status-types' +import { + selectAutomationAgentStatusEntryChange, + UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY +} from './automation-agent-status-entry-change' +import type { Worktree } from '../../../shared/worktree/types' + +type MarkDispatchResult = (result: AutomationDispatchResult) => Promise + +export function createAutomationDispatchCompletion(args: { + run: AutomationRun + worktree: Worktree + precheckResult: AutomationPrecheckResult | null + markDispatchResult: MarkDispatchResult + releaseTerminalOwnership: () => void + finalizeTerminalOwnership: () => boolean +}) { + const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() + let latestAssistantMessage: string | null = null + const getOutputSnapshot = () => + selectAutomationRunOutputSnapshot(latestAssistantMessage, outputSnapshotBuffer.snapshot()) + let dispatchMarked = false + let pendingExitCode: number | null = null + let pendingDone = false + let completionMarked = false + let unsubscribeAgentStatus = (): void => {} + let unsubscribeSessionObserver = (): void => {} + let releaseReuseDispatchTab = (): void => {} + const cleanupRunObservers = (): void => { + unsubscribeAgentStatus() + unsubscribeSessionObserver() + releaseReuseDispatchTab() + unsubscribeAgentStatus = (): void => {} + unsubscribeSessionObserver = (): void => {} + releaseReuseDispatchTab = (): void => {} + } + const markCompletionResult = async (): Promise => { + if (completionMarked) { + return + } + completionMarked = true + cleanupRunObservers() + try { + await args.markDispatchResult({ + runId: args.run.id, + status: 'completed', + workspaceId: args.worktree.id, + workspaceDisplayName: args.worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult: args.precheckResult, + error: null + }) + } catch (error) { + args.releaseTerminalOwnership() + throw error + } + if (args.finalizeTerminalOwnership()) { + await clearRetiredRunTerminalIdentity() + } + } + const clearRetiredRunTerminalIdentity = async (): Promise => { + // Why: the owned terminal was just retired, so the run's pane/pty + // pointers now reference a closed tab. Drop them (best-effort) so + // "View run" resolves to the workspace/snapshot instead of dead-ending + // on an unavailable terminal. + try { + await args.markDispatchResult({ + runId: args.run.id, + status: 'completed', + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null + }) + } catch (error) { + console.error('[automations] Failed to clear retired terminal identity:', error) + } + } + const markExitResult = async (code: number): Promise => { + if (completionMarked) { + return + } + completionMarked = true + cleanupRunObservers() + try { + await args.markDispatchResult({ + runId: args.run.id, + status: code === 0 ? 'completed' : 'dispatch_failed', + workspaceId: args.worktree.id, + workspaceDisplayName: args.worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult: args.precheckResult, + error: code === 0 ? null : `Automation process exited with code ${code}.` + }) + } catch (error) { + args.releaseTerminalOwnership() + throw error + } + if (code === 0) { + if (args.finalizeTerminalOwnership()) { + await clearRetiredRunTerminalIdentity() + } + } else { + args.releaseTerminalOwnership() + } + } + const settleLateResult = (result: Promise): void => { + // Why: status/exit callbacks have no awaitable caller; the result + // path already releases ownership before propagating persistence errors. + void result.catch((error) => { + console.error('[automations] Failed to persist late automation result:', error) + }) + } + const handleAgentDone = (): void => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingDone = true + return + } + settleLateResult(markCompletionResult()) + } + const handleExit = (code: number): void => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingExitCode = code + return + } + settleLateResult(markExitResult(code)) + } + const observeAgentStatus = ( + targetPaneKey: string, + startedAfter: number, + options?: { requireWorkingAfterStart?: boolean } + ): void => { + let sawWorkingAfterStart = false + let observedStateHistory: AgentStateHistoryEntry[] = [] + let observedEntry: AgentStatusEntry | undefined + const checkCurrentStatus = (): void => { + const entryChange = selectAutomationAgentStatusEntryChange( + useAppStore.getState().agentStatusByPaneKey, + targetPaneKey, + observedEntry + ) + if (entryChange === UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) { + return + } + const entry = entryChange + observedEntry = entry + if (!entry || entry.updatedAt < startedAfter) { + return + } + const historyOverlap = getAgentStateHistoryOverlap(observedStateHistory, entry.stateHistory) + // Why: sawWorkingAfterStart stays monotonic — a recreated entry + // (transport loss, PTY exit, cap eviction) arrives with an empty + // stateHistory, so clearing it here would strand reuseSession runs + // with nothing left to re-derive the working edge from. + for (const historicalState of entry.stateHistory.slice(historyOverlap)) { + if (historicalState.startedAt < startedAfter) { + continue + } + if (historicalState.state === 'working') { + sawWorkingAfterStart = true + } + if ( + historicalState.state === 'done' && + (!options?.requireWorkingAfterStart || sawWorkingAfterStart) + ) { + // Why: this `done` already rolled out of the live entry, so its output + // survives only in the entry-level completed slot. + latestAssistantMessage = + entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage + handleAgentDone() + return + } + } + observedStateHistory = [...entry.stateHistory] + if (entry.state === 'working') { + sawWorkingAfterStart = true + } + if ( + entry.state === 'done' && + // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart + // fires at launch, before the argv prompt submits) — completing here would + // close the tab and record an empty run result. + entry.sessionBoundary !== true && + (!options?.requireWorkingAfterStart || sawWorkingAfterStart) + ) { + latestAssistantMessage = entry.lastAssistantMessage?.trim() || latestAssistantMessage + handleAgentDone() + } + } + // Why: Codex/Claude completion normally arrives through the global + // hook IPC listener, not the hidden PTY OSC fallback. + unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus) + checkCurrentStatus() + } + + return { + appendOutput: (chunk: string) => outputSnapshotBuffer.append(chunk), + captureAssistantMessage: (message: string | null | undefined) => { + latestAssistantMessage = message?.trim() || latestAssistantMessage + }, + cleanupRunObservers, + handleAgentDone, + handleExit, + observeAgentStatus, + setReuseDispatchTabRelease: (release: () => void) => { + releaseReuseDispatchTab = release + }, + setSessionObserver: (unsubscribe: () => void) => { + unsubscribeSessionObserver = unsubscribe + }, + settlePendingAfterDispatch: async () => { + dispatchMarked = true + if (pendingDone) { + await markCompletionResult() + } else if (pendingExitCode !== null) { + await markExitResult(pendingExitCode) + } + } + } +} + +function agentStateHistoryEntriesEqual( + left: AgentStateHistoryEntry, + right: AgentStateHistoryEntry +): boolean { + return ( + left.state === right.state && + left.prompt === right.prompt && + left.startedAt === right.startedAt && + left.interrupted === right.interrupted + ) +} + +function getAgentStateHistoryOverlap( + previous: AgentStateHistoryEntry[], + current: AgentStateHistoryEntry[] +): number { + for (let overlap = Math.min(previous.length, current.length); overlap > 0; overlap -= 1) { + const previousOffset = previous.length - overlap + if ( + current + .slice(0, overlap) + .every((entry, index) => + agentStateHistoryEntriesEqual(entry, previous[previousOffset + index]) + ) + ) { + return overlap + } + } + return 0 +} diff --git a/src/renderer/src/hooks/automation-dispatch-handler.ts b/src/renderer/src/hooks/automation-dispatch-handler.ts new file mode 100644 index 00000000000..6df1a64fb58 --- /dev/null +++ b/src/renderer/src/hooks/automation-dispatch-handler.ts @@ -0,0 +1,239 @@ +import { listAutomationRunsForTarget } from '@/components/automations/automation-host-client' +import { translate } from '@/i18n/i18n' +import { submitPromptToAgentPty } from '@/lib/agent-paste-draft' +import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session' +import { observeExistingAutomationSession } from '@/lib/automation-session-observer' +import { findReusableAutomationSession } from '@/lib/automation-session-reuse' +import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' +import { useAppStore } from '@/store' +import type { + AutomationDispatchRequest, + AutomationDispatchResult +} from '../../../shared/automations-types' +import { createAutomationDispatchCompletion } from './automation-dispatch-completion' +import { + prepareAutomationDispatchWorkspace, + resolveAutomationDispatchWorkspace +} from './automation-dispatch-workspace' + +const activeReuseDispatchTabIds = new Set() + +function acquireReuseDispatchTab(tabId: string): (() => void) | null { + if (activeReuseDispatchTabIds.has(tabId)) { + return null + } + activeReuseDispatchTabIds.add(tabId) + return () => activeReuseDispatchTabIds.delete(tabId) +} + +export async function handleAutomationDispatchRequest({ + automation, + run, + dispatchToken +}: AutomationDispatchRequest): Promise { + const markDispatchResult = async (result: AutomationDispatchResult): Promise => { + // Deliberately no local emit: the write publishes its own host-scoped event + // (automation-run-writer.ts) before this reply returns, and an unscoped one + // here would arrive second and invalidate every host in the catalog. + await window.api.automations.markDispatchResult(result) + } + const state = useAppStore.getState() + const focusBeforeDispatch = { + activeView: state.activeView, + activeWorktreeId: state.activeWorktreeId, + activeTabId: state.activeTabId, + activeTabType: state.activeTabType + } + const resolved = resolveAutomationDispatchWorkspace(state, automation, run) + let terminalOwnership: AutomationTerminalOwnership | null = null + const releaseTerminalOwnership = (): void => { + const ownership = terminalOwnership + terminalOwnership = null + ownership?.release() + } + const finalizeTerminalOwnership = (): boolean => { + const ownership = terminalOwnership + terminalOwnership = null + return ownership?.finalize() ?? false + } + + if (!resolved.repo) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: run.workspaceId, + workspaceDisplayName: run.workspaceDisplayName ?? null, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.386db94f3e', + 'The target project is no longer available.' + ) + }) + return + } + + try { + const worktree = await prepareAutomationDispatchWorkspace({ + state, + automation, + run, + dispatchToken, + resolved: { ...resolved, repo: resolved.repo }, + markDispatchResult + }) + if (!worktree) { + return + } + const completion = createAutomationDispatchCompletion({ + run, + worktree, + precheckResult: resolved.context.precheckResult, + markDispatchResult, + releaseTerminalOwnership, + finalizeTerminalOwnership + }) + const dispatchStartedAt = Date.now() + if (automation.reuseSession) { + const reusableSession = findReusableAutomationSession({ + automationId: automation.id, + agentId: automation.agentId, + worktreeId: worktree.id, + currentRunId: run.id, + // Why: the dispatch loop only ever executes for the desktop authority, + // so its history read addresses the local runtime explicitly. + runs: await listAutomationRunsForTarget({ kind: 'local' }, automation.id), + state: useAppStore.getState() + }) + if (reusableSession) { + const releaseTab = acquireReuseDispatchTab(reusableSession.tabId) + if (releaseTab) { + completion.setReuseDispatchTabRelease(releaseTab) + try { + const submitted = await submitPromptToAgentPty({ + tabId: reusableSession.tabId, + ptyId: reusableSession.ptyId, + content: automation.prompt + }) + if (!submitted) { + completion.cleanupRunObservers() + } else { + let reuseSawWorking = false + const handleReusableAgentStatus = (payload: { state: string }): void => { + if (payload.state === 'working') { + reuseSawWorking = true + return + } + if (payload.state === 'done' && reuseSawWorking) { + completion.handleAgentDone() + } + } + const reuseCompletionStartedAt = Date.now() + completion.setSessionObserver( + await observeExistingAutomationSession({ + ptyId: reusableSession.ptyId, + paneKey: reusableSession.paneKey, + runId: run.id, + onData: completion.appendOutput, + onAgentStatus: (payload) => { + completion.captureAssistantMessage(payload.lastAssistantMessage) + handleReusableAgentStatus(payload) + }, + onExit: completion.handleExit + }) + ) + completion.observeAgentStatus(reusableSession.paneKey, reuseCompletionStartedAt, { + requireWorkingAfterStart: true + }) + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + terminalSessionId: reusableSession.tabId, + terminalPaneKey: reusableSession.paneKey, + terminalPtyId: reusableSession.ptyId, + precheckResult: resolved.context.precheckResult, + error: null + }) + await completion.settlePendingAfterDispatch() + return + } + } catch (error) { + completion.cleanupRunObservers() + throw error + } + } + } + } + const result = await launchAgentBackgroundSession({ + agent: automation.agentId, + worktreeId: worktree.id, + prompt: automation.prompt, + launchSource: 'unknown', + title: run.title, + onData: completion.appendOutput, + onAgentStatus: (payload) => { + completion.captureAssistantMessage(payload.lastAssistantMessage) + // Why: session-boundary done = launch connect, not run completion (see observeAgentStatus). + if (payload.state !== 'done' || payload.sessionBoundary === true) { + return + } + completion.handleAgentDone() + }, + onExit: (_ptyId, code) => { + completion.handleExit(code) + } + }) + if (!result) { + throw new Error('Unable to build an agent launch plan.') + } + terminalOwnership = result.terminalOwnership + if (automation.reuseSession) { + // Why: the first fresh launch is the seed for later reuse and must + // survive completion under the same policy as an already-reused tab. + releaseTerminalOwnership() + } + const launchedTabId = result.tabId + completion.observeAgentStatus(result.paneKey, dispatchStartedAt) + try { + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + terminalSessionId: launchedTabId, + terminalPaneKey: result.paneKey, + terminalPtyId: result.ptyId, + precheckResult: resolved.context.precheckResult, + error: null + }) + await completion.settlePendingAfterDispatch() + } catch (error) { + completion.cleanupRunObservers() + throw error + } + const currentState = useAppStore.getState() + // Why: Run Now and scheduled dispatches should create workspaces/tabs in + // the background; only an explicit row click should navigate there. + if ( + focusBeforeDispatch.activeWorktreeId !== worktree.id && + currentState.activeWorktreeId === worktree.id + ) { + currentState.setActiveView(focusBeforeDispatch.activeView) + currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId) + if (focusBeforeDispatch.activeTabId) { + currentState.setActiveTab(focusBeforeDispatch.activeTabId) + } + currentState.setActiveTabType(focusBeforeDispatch.activeTabType) + } + } catch (error) { + releaseTerminalOwnership() + await markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: resolved.context.workspaceId, + workspaceDisplayName: resolved.context.workspaceDisplayName, + precheckResult: resolved.context.precheckResult, + error: error instanceof Error ? error.message : String(error) + }) + } +} diff --git a/src/renderer/src/hooks/automation-dispatch-workspace.ts b/src/renderer/src/hooks/automation-dispatch-workspace.ts new file mode 100644 index 00000000000..f8980a56108 --- /dev/null +++ b/src/renderer/src/hooks/automation-dispatch-workspace.ts @@ -0,0 +1,269 @@ +import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import type { + Automation, + AutomationDispatchResult, + AutomationPrecheckResult, + AutomationRun +} from '../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' +import { + didAutomationPrecheckPass, + formatAutomationPrecheckFailure +} from '../../../shared/automation-precheck' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' +import type { Worktree } from '../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' + +type AutomationDispatchStoreState = ReturnType +type MarkDispatchResult = (result: AutomationDispatchResult) => Promise + +export type AutomationDispatchWorkspaceContext = { + workspaceId: string | null + workspaceDisplayName: string | null + precheckResult: AutomationPrecheckResult | null +} + +export function resolveAutomationDispatchWorkspace( + state: AutomationDispatchStoreState, + automation: Automation, + run: AutomationRun +) { + const runRepoId = getAutomationRunRepoId(automation) + const repo = state.repos.find((entry) => entry.id === runRepoId) + const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '') + const automationWorktree = automation.workspaceId + ? automationWorkspaceScope?.type === 'folder' + ? state.getKnownWorktreeById(automation.workspaceId) + : state.allWorktrees().find((entry) => entry.id === automation.workspaceId) + : null + const context: AutomationDispatchWorkspaceContext = { + workspaceId: automation.workspaceId, + workspaceDisplayName: automationWorktree?.displayName ?? run.workspaceDisplayName ?? null, + precheckResult: null + } + return { runRepoId, repo, automationWorkspaceScope, automationWorktree, context } +} + +type ResolvedAutomationDispatchWorkspace = ReturnType + +export async function prepareAutomationDispatchWorkspace(args: { + state: AutomationDispatchStoreState + automation: Automation + run: AutomationRun + dispatchToken: string + resolved: ResolvedAutomationDispatchWorkspace & { + repo: NonNullable + } + markDispatchResult: MarkDispatchResult +}): Promise { + const { state, automation, run, dispatchToken, resolved, markDispatchResult } = args + const { repo, runRepoId, automationWorkspaceScope, automationWorktree, context } = resolved + const folderWorkspaceHost = + automationWorkspaceScope?.type === 'folder' + ? resolveFolderWorkspaceHost(state, automationWorkspaceScope.folderWorkspaceId) + : null + const folderWorkspaceConnectionId = + folderWorkspaceHost?.kind === 'ssh' ? folderWorkspaceHost.targetId : null + // A workspace whose host does not resolve to one place is refused, not guessed at. + const folderWorkspaceHostUnresolved = + folderWorkspaceHost !== null && folderWorkspaceHost.kind === 'ambiguous' + const folderWorkspaceHostId = + folderWorkspaceHost && automationWorktree + ? folderWorkspaceConnectionId + ? toSshExecutionHostId(folderWorkspaceConnectionId) + : folderWorkspaceHost.kind === 'local' + ? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + : null + : null + const runHostId = + parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo) + const workspaceMatchesRunTarget = + automationWorkspaceScope?.type === 'folder' + ? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId + : !automation.runContext?.repoId || + automationWorktree?.repoId === automation.runContext.repoId + if (automation.workspaceMode === 'existing' && automationWorktree && !workspaceMatchesRunTarget) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: context.workspaceDisplayName, + error: folderWorkspaceHostUnresolved + ? translate( + 'auto.hooks.useAutomationDispatchEvents.workspaceHostUnresolved', + 'The target workspace spans more than one host, so this run has no single host to use.' + ) + : translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) + }) + return null + } + const sshTargetId = + automationWorkspaceScope?.type === 'folder' + ? (folderWorkspaceConnectionId ?? null) + : (repo.connectionId ?? null) + if (sshTargetId) { + const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ + targetId: sshTargetId + }) + if (needsPrompt) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_needs_interactive_auth', + workspaceId: context.workspaceId, + workspaceDisplayName: context.workspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.16a21d6413', + 'SSH reconnect requires interactive credentials.' + ) + }) + return null + } + const sshState = await window.api.ssh.getState({ targetId: sshTargetId }) + if (sshState?.status !== 'connected') { + try { + const connected = await window.api.ssh.connect({ targetId: sshTargetId }) + if (connected?.status !== 'connected') { + throw new Error('SSH target is unavailable.') + } + } catch (error) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: context.workspaceId, + workspaceDisplayName: context.workspaceDisplayName, + error: error instanceof Error ? error.message : String(error) + }) + return null + } + } + } + + if (automation.workspaceMode === 'existing' && !automationWorktree) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: context.workspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.59718b120b', + 'The target workspace is no longer available.' + ) + }) + return null + } + + if (run.trigger === 'scheduled' && automation.precheck) { + context.precheckResult = await window.api.automations.runPrecheck({ + automationId: automation.id, + runId: run.id + }) + if (context.precheckResult && !didAutomationPrecheckPass(context.precheckResult)) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: context.workspaceId, + workspaceDisplayName: context.workspaceDisplayName, + precheckResult: context.precheckResult, + error: formatAutomationPrecheckFailure(context.precheckResult) + }) + return null + } + } + + const automationWorkspaceCreateRequestId = createBrowserUuid() + const createResult = + automation.workspaceMode === 'new_per_run' + ? await useAppStore.getState().createWorktree( + runRepoId, + buildAutomationWorkspaceName(run.title, run.scheduledFor), + automation.baseBranch ?? undefined, + automation.setupDecision ?? 'skip', + undefined, + 'unknown', + run.title, + undefined, + undefined, + undefined, + // Why: the automation session below owns the prompt-bearing + // agent tab; createdWithAgent would reopen an empty fallback. + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + automationProvenanceRequest: { + automationId: automation.id, + automationRunId: run.id, + dispatchToken, + createRequestId: automationWorkspaceCreateRequestId + } + } + ) + : null + const worktree = createResult + ? createResult.worktree + : automation.workspaceId + ? automationWorktree + : null + + if (!worktree) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: context.workspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.59718b120b', + 'The target workspace is no longer available.' + ) + }) + return null + } + context.workspaceId = worktree.id + context.workspaceDisplayName = worktree.displayName + if (createResult?.setup || createResult?.defaultTabs) { + void launchWorktreeBackgroundTerminals({ + worktreeId: worktree.id, + setup: createResult.setup, + defaultTabs: createResult.defaultTabs + }).catch((error) => { + // Why: setup/defaultTabs match normal worktree creation: they are + // best-effort terminal work and must not block the automation agent. + console.warn('[automations] Failed to launch workspace setup/default tabs:', error) + }) + } + return worktree +} + +function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): string { + const slug = runTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) + const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13) + return `auto-${slug || 'run'}-${stamp}` +} diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index e06eefa6e61..f4963a6dea7 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -1,700 +1,9 @@ -/* eslint-disable max-lines -- Why: automation dispatch is a single renderer lifecycle - * coordinator spanning workspace creation, SSH readiness, terminal launch/reuse, - * completion bookkeeping, and focus restoration. */ import { useEffect } from 'react' -import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session' -import { submitPromptToAgentPty } from '@/lib/agent-paste-draft' -import { findReusableAutomationSession } from '@/lib/automation-session-reuse' -import { observeExistingAutomationSession } from '@/lib/automation-session-observer' -import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals' -import { useAppStore } from '@/store' -import type { - AutomationDispatchResult, - AutomationPrecheckResult -} from '../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' -import { - didAutomationPrecheckPass, - formatAutomationPrecheckFailure -} from '../../../shared/automation-precheck' -import { - createAutomationRunOutputSnapshotBuffer, - selectAutomationRunOutputSnapshot -} from '@/components/automations/automation-run-output-snapshot' -import { translate } from '@/i18n/i18n' -import { createBrowserUuid } from '@/lib/browser-uuid' -import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' -import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' -import { listAutomationRunsForTarget } from '@/components/automations/automation-host-client' -import { - getRepoExecutionHostId, - parseExecutionHostId, - toSshExecutionHostId -} from '../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { AgentStateHistoryEntry, AgentStatusEntry } from '../../../shared/agent-status-types' -import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' -import { - selectAutomationAgentStatusEntryChange, - UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY -} from './automation-agent-status-entry-change' - -const activeReuseDispatchTabIds = new Set() - -function acquireReuseDispatchTab(tabId: string): (() => void) | null { - if (activeReuseDispatchTabIds.has(tabId)) { - return null - } - activeReuseDispatchTabIds.add(tabId) - return () => activeReuseDispatchTabIds.delete(tabId) -} - -function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): string { - const slug = runTitle - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 40) - const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13) - return `auto-${slug || 'run'}-${stamp}` -} - -function agentStateHistoryEntriesEqual( - left: AgentStateHistoryEntry, - right: AgentStateHistoryEntry -): boolean { - return ( - left.state === right.state && - left.prompt === right.prompt && - left.startedAt === right.startedAt && - left.interrupted === right.interrupted - ) -} - -function getAgentStateHistoryOverlap( - previous: AgentStateHistoryEntry[], - current: AgentStateHistoryEntry[] -): number { - for (let overlap = Math.min(previous.length, current.length); overlap > 0; overlap -= 1) { - const previousOffset = previous.length - overlap - if ( - current - .slice(0, overlap) - .every((entry, index) => - agentStateHistoryEntriesEqual(entry, previous[previousOffset + index]) - ) - ) { - return overlap - } - } - return 0 -} +import { handleAutomationDispatchRequest } from './automation-dispatch-handler' export function useAutomationDispatchEvents(): void { useEffect(() => { - const unsubscribe = window.api.automations.onDispatchRequested( - async ({ automation, run, dispatchToken }) => { - const markDispatchResult = async (result: AutomationDispatchResult): Promise => { - // Deliberately no local emit: the write publishes its own host-scoped event - // (automation-run-writer.ts) before this reply returns, and an unscoped one - // here would arrive second and invalidate every host in the catalog. - await window.api.automations.markDispatchResult(result) - } - const state = useAppStore.getState() - const focusBeforeDispatch = { - activeView: state.activeView, - activeWorktreeId: state.activeWorktreeId, - activeTabId: state.activeTabId, - activeTabType: state.activeTabType - } - const runRepoId = getAutomationRunRepoId(automation) - const repo = state.repos.find((entry) => entry.id === runRepoId) - const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '') - const automationWorktree = automation.workspaceId - ? automationWorkspaceScope?.type === 'folder' - ? state.getKnownWorktreeById(automation.workspaceId) - : state.allWorktrees().find((entry) => entry.id === automation.workspaceId) - : null - let dispatchWorkspaceId = automation.workspaceId - let dispatchWorkspaceDisplayName = - automationWorktree?.displayName ?? run.workspaceDisplayName ?? null - let precheckResult: AutomationPrecheckResult | null = null - let terminalOwnership: AutomationTerminalOwnership | null = null - const releaseTerminalOwnership = (): void => { - const ownership = terminalOwnership - terminalOwnership = null - ownership?.release() - } - const finalizeTerminalOwnership = (): boolean => { - const ownership = terminalOwnership - terminalOwnership = null - return ownership?.finalize() ?? false - } - - if (!repo) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: run.workspaceId, - workspaceDisplayName: run.workspaceDisplayName ?? null, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.386db94f3e', - 'The target project is no longer available.' - ) - }) - return - } - - try { - const folderWorkspaceHost = - automationWorkspaceScope?.type === 'folder' - ? resolveFolderWorkspaceHost(state, automationWorkspaceScope.folderWorkspaceId) - : null - const folderWorkspaceConnectionId = - folderWorkspaceHost?.kind === 'ssh' ? folderWorkspaceHost.targetId : null - // A workspace whose host does not resolve to one place is refused, not guessed at. - const folderWorkspaceHostUnresolved = - folderWorkspaceHost !== null && folderWorkspaceHost.kind === 'ambiguous' - const folderWorkspaceHostId = - folderWorkspaceHost && automationWorktree - ? folderWorkspaceConnectionId - ? toSshExecutionHostId(folderWorkspaceConnectionId) - : folderWorkspaceHost.kind === 'local' - ? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) - : null - : null - const runHostId = - parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo) - const workspaceMatchesRunTarget = - automationWorkspaceScope?.type === 'folder' - ? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId - : !automation.runContext?.repoId || - automationWorktree?.repoId === automation.runContext.repoId - if ( - automation.workspaceMode === 'existing' && - automationWorktree && - !workspaceMatchesRunTarget - ) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: folderWorkspaceHostUnresolved - ? translate( - 'auto.hooks.useAutomationDispatchEvents.workspaceHostUnresolved', - 'The target workspace spans more than one host, so this run has no single host to use.' - ) - : translate( - 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', - 'The target workspace is on a different host than this automation run target.' - ) - }) - return - } - const sshTargetId = - automationWorkspaceScope?.type === 'folder' - ? (folderWorkspaceConnectionId ?? null) - : (repo.connectionId ?? null) - if (sshTargetId) { - const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ - targetId: sshTargetId - }) - if (needsPrompt) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_needs_interactive_auth', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.16a21d6413', - 'SSH reconnect requires interactive credentials.' - ) - }) - return - } - const sshState = await window.api.ssh.getState({ targetId: sshTargetId }) - if (sshState?.status !== 'connected') { - try { - const connected = await window.api.ssh.connect({ targetId: sshTargetId }) - if (connected?.status !== 'connected') { - throw new Error('SSH target is unavailable.') - } - } catch (error) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: error instanceof Error ? error.message : String(error) - }) - return - } - } - } - - if (automation.workspaceMode === 'existing' && !automationWorktree) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.59718b120b', - 'The target workspace is no longer available.' - ) - }) - return - } - - if (run.trigger === 'scheduled' && automation.precheck) { - precheckResult = await window.api.automations.runPrecheck({ - automationId: automation.id, - runId: run.id - }) - if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_precheck', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - precheckResult, - error: formatAutomationPrecheckFailure(precheckResult) - }) - return - } - } - - const automationWorkspaceCreateRequestId = createBrowserUuid() - const createResult = - automation.workspaceMode === 'new_per_run' - ? await useAppStore.getState().createWorktree( - runRepoId, - buildAutomationWorkspaceName(run.title, run.scheduledFor), - automation.baseBranch ?? undefined, - automation.setupDecision ?? 'skip', - undefined, - 'unknown', - run.title, - undefined, - undefined, - undefined, - // Why: the automation session below owns the prompt-bearing - // agent tab; createdWithAgent would reopen an empty fallback. - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - automationProvenanceRequest: { - automationId: automation.id, - automationRunId: run.id, - dispatchToken, - createRequestId: automationWorkspaceCreateRequestId - } - } - ) - : null - const worktree = createResult - ? createResult.worktree - : automation.workspaceId - ? automationWorktree - : null - - if (!worktree) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.59718b120b', - 'The target workspace is no longer available.' - ) - }) - return - } - dispatchWorkspaceId = worktree.id - dispatchWorkspaceDisplayName = worktree.displayName - if (createResult?.setup || createResult?.defaultTabs) { - void launchWorktreeBackgroundTerminals({ - worktreeId: worktree.id, - setup: createResult.setup, - defaultTabs: createResult.defaultTabs - }).catch((error) => { - // Why: setup/defaultTabs match normal worktree creation: they are - // best-effort terminal work and must not block the automation agent. - console.warn('[automations] Failed to launch workspace setup/default tabs:', error) - }) - } - - const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() - let latestAssistantMessage: string | null = null - const getOutputSnapshot = () => - selectAutomationRunOutputSnapshot( - latestAssistantMessage, - outputSnapshotBuffer.snapshot() - ) - let dispatchMarked = false - let pendingExitCode: number | null = null - let pendingDone = false - let completionMarked = false - let unsubscribeAgentStatus = (): void => {} - let unsubscribeSessionObserver = (): void => {} - let releaseReuseDispatchTab = (): void => {} - const cleanupRunObservers = (): void => { - unsubscribeAgentStatus() - unsubscribeSessionObserver() - releaseReuseDispatchTab() - unsubscribeAgentStatus = (): void => {} - unsubscribeSessionObserver = (): void => {} - releaseReuseDispatchTab = (): void => {} - } - const markCompletionResult = async (): Promise => { - if (completionMarked) { - return - } - completionMarked = true - cleanupRunObservers() - try { - await markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: null - }) - } catch (error) { - releaseTerminalOwnership() - throw error - } - if (finalizeTerminalOwnership()) { - await clearRetiredRunTerminalIdentity() - } - } - const clearRetiredRunTerminalIdentity = async (): Promise => { - // Why: the owned terminal was just retired, so the run's pane/pty - // pointers now reference a closed tab. Drop them (best-effort) so - // "View run" resolves to the workspace/snapshot instead of dead-ending - // on an unavailable terminal. - try { - await markDispatchResult({ - runId: run.id, - status: 'completed', - terminalSessionId: null, - terminalPaneKey: null, - terminalPtyId: null - }) - } catch (error) { - console.error('[automations] Failed to clear retired terminal identity:', error) - } - } - const markExitResult = async (code: number): Promise => { - if (completionMarked) { - return - } - completionMarked = true - cleanupRunObservers() - try { - await markDispatchResult({ - runId: run.id, - status: code === 0 ? 'completed' : 'dispatch_failed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: code === 0 ? null : `Automation process exited with code ${code}.` - }) - } catch (error) { - releaseTerminalOwnership() - throw error - } - if (code === 0) { - if (finalizeTerminalOwnership()) { - await clearRetiredRunTerminalIdentity() - } - } else { - releaseTerminalOwnership() - } - } - const settleLateResult = (result: Promise): void => { - // Why: status/exit callbacks have no awaitable caller; the result - // path already releases ownership before propagating persistence errors. - void result.catch((error) => { - console.error('[automations] Failed to persist late automation result:', error) - }) - } - const handleAgentDone = (): void => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingDone = true - return - } - settleLateResult(markCompletionResult()) - } - const observeAgentStatus = ( - targetPaneKey: string, - startedAfter: number, - options?: { requireWorkingAfterStart?: boolean } - ): void => { - let sawWorkingAfterStart = false - let observedStateHistory: AgentStateHistoryEntry[] = [] - let observedEntry: AgentStatusEntry | undefined - const checkCurrentStatus = (): void => { - const entryChange = selectAutomationAgentStatusEntryChange( - useAppStore.getState().agentStatusByPaneKey, - targetPaneKey, - observedEntry - ) - if (entryChange === UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) { - return - } - const entry = entryChange - observedEntry = entry - if (!entry || entry.updatedAt < startedAfter) { - return - } - const historyOverlap = getAgentStateHistoryOverlap( - observedStateHistory, - entry.stateHistory - ) - // Why: sawWorkingAfterStart stays monotonic — a recreated entry - // (transport loss, PTY exit, cap eviction) arrives with an empty - // stateHistory, so clearing it here would strand reuseSession runs - // with nothing left to re-derive the working edge from. - for (const historicalState of entry.stateHistory.slice(historyOverlap)) { - if (historicalState.startedAt < startedAfter) { - continue - } - if (historicalState.state === 'working') { - sawWorkingAfterStart = true - } - if ( - historicalState.state === 'done' && - (!options?.requireWorkingAfterStart || sawWorkingAfterStart) - ) { - // Why: this `done` already rolled out of the live entry, so its output - // survives only in the entry-level completed slot. - latestAssistantMessage = - entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage - handleAgentDone() - return - } - } - observedStateHistory = [...entry.stateHistory] - if (entry.state === 'working') { - sawWorkingAfterStart = true - } - if ( - entry.state === 'done' && - // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart - // fires at launch, before the argv prompt submits) — completing here would - // close the tab and record an empty run result. - entry.sessionBoundary !== true && - (!options?.requireWorkingAfterStart || sawWorkingAfterStart) - ) { - latestAssistantMessage = - entry.lastAssistantMessage?.trim() || latestAssistantMessage - handleAgentDone() - } - } - // Why: Codex/Claude completion normally arrives through the global - // hook IPC listener, not the hidden PTY OSC fallback. - unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus) - checkCurrentStatus() - } - const dispatchStartedAt = Date.now() - if (automation.reuseSession) { - const reusableSession = findReusableAutomationSession({ - automationId: automation.id, - agentId: automation.agentId, - worktreeId: worktree.id, - currentRunId: run.id, - // Why: the dispatch loop only ever executes for the desktop authority, - // so its history read addresses the local runtime explicitly. - runs: await listAutomationRunsForTarget({ kind: 'local' }, automation.id), - state: useAppStore.getState() - }) - if (reusableSession) { - const releaseTab = acquireReuseDispatchTab(reusableSession.tabId) - if (releaseTab) { - releaseReuseDispatchTab = releaseTab - try { - const submitted = await submitPromptToAgentPty({ - tabId: reusableSession.tabId, - ptyId: reusableSession.ptyId, - content: automation.prompt - }) - if (!submitted) { - cleanupRunObservers() - } else { - let reuseSawWorking = false - const handleReusableAgentStatus = (payload: { state: string }): void => { - if (payload.state === 'working') { - reuseSawWorking = true - return - } - if (payload.state === 'done' && reuseSawWorking) { - handleAgentDone() - } - } - const reuseCompletionStartedAt = Date.now() - unsubscribeSessionObserver = await observeExistingAutomationSession({ - ptyId: reusableSession.ptyId, - paneKey: reusableSession.paneKey, - runId: run.id, - onData: (chunk) => { - outputSnapshotBuffer.append(chunk) - }, - onAgentStatus: (payload) => { - latestAssistantMessage = - payload.lastAssistantMessage?.trim() || latestAssistantMessage - handleReusableAgentStatus(payload) - }, - onExit: (code) => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingExitCode = code - return - } - settleLateResult(markExitResult(code)) - } - }) - observeAgentStatus(reusableSession.paneKey, reuseCompletionStartedAt, { - requireWorkingAfterStart: true - }) - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - terminalSessionId: reusableSession.tabId, - terminalPaneKey: reusableSession.paneKey, - terminalPtyId: reusableSession.ptyId, - precheckResult, - error: null - }) - dispatchMarked = true - if (pendingDone) { - await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) - } - return - } - } catch (error) { - cleanupRunObservers() - throw error - } - } - } - } - const result = await launchAgentBackgroundSession({ - agent: automation.agentId, - worktreeId: worktree.id, - prompt: automation.prompt, - launchSource: 'unknown', - title: run.title, - onData: (chunk) => { - outputSnapshotBuffer.append(chunk) - }, - onAgentStatus: (payload) => { - latestAssistantMessage = - payload.lastAssistantMessage?.trim() || latestAssistantMessage - // Why: session-boundary done = launch connect, not run completion (see observeAgentStatus). - if (payload.state !== 'done' || payload.sessionBoundary === true) { - return - } - handleAgentDone() - }, - onExit: (_ptyId, code) => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingExitCode = code - return - } - settleLateResult(markExitResult(code)) - } - }) - if (!result) { - throw new Error('Unable to build an agent launch plan.') - } - terminalOwnership = result.terminalOwnership - if (automation.reuseSession) { - // Why: the first fresh launch is the seed for later reuse and must - // survive completion under the same policy as an already-reused tab. - releaseTerminalOwnership() - } - const launchedTabId = result.tabId - observeAgentStatus(result.paneKey, dispatchStartedAt) - try { - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - terminalSessionId: launchedTabId, - terminalPaneKey: result.paneKey, - terminalPtyId: result.ptyId, - precheckResult, - error: null - }) - dispatchMarked = true - if (pendingDone) { - await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) - } - } catch (error) { - cleanupRunObservers() - throw error - } - const currentState = useAppStore.getState() - // Why: Run Now and scheduled dispatches should create workspaces/tabs in - // the background; only an explicit row click should navigate there. - if ( - focusBeforeDispatch.activeWorktreeId !== worktree.id && - currentState.activeWorktreeId === worktree.id - ) { - currentState.setActiveView(focusBeforeDispatch.activeView) - currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId) - if (focusBeforeDispatch.activeTabId) { - currentState.setActiveTab(focusBeforeDispatch.activeTabId) - } - currentState.setActiveTabType(focusBeforeDispatch.activeTabType) - } - } catch (error) { - releaseTerminalOwnership() - await markDispatchResult({ - runId: run.id, - status: 'dispatch_failed', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - precheckResult, - error: error instanceof Error ? error.message : String(error) - }) - } - } - ) + const unsubscribe = window.api.automations.onDispatchRequested(handleAutomationDispatchRequest) void window.api.automations.rendererReady() return unsubscribe }, [])