diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 6eafb567889..35164220fca 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -89,6 +89,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, + skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index d1cac96eb3a..3a44b1bf7f6 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -82,6 +82,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, + skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 342e8d7887f..09d5c9ebbb9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,9 +1,24 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail * orchestration while the form and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CalendarClock, Plus, RefreshCw } from 'lucide-react' +import { CalendarClock, Check, Pause, Pencil, Play, Plus, RefreshCw, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -40,9 +55,14 @@ export default function AutomationsPage(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) + const updateSettings = useAppStore((s) => s.updateSettings) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const settings = useAppStore((s) => s.settings) + const selectedId = useAppStore((s) => s.selectedAutomationId) + const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() const defaultAgent = @@ -52,14 +72,16 @@ export default function AutomationsPage(): React.JSX.Element { const [automations, setAutomations] = useState([]) const [runs, setRuns] = useState([]) - const [selectedId, setSelectedId] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [editingAutomationId, setEditingAutomationId] = useState(null) const [relativeNow, setRelativeNow] = useState(Date.now()) const [draftAtOpen, setDraftAtOpen] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) + const deleteConfirmButtonRef = useRef(null) const [draft, setDraft] = useState({ name: '', prompt: '', @@ -113,11 +135,17 @@ export default function AutomationsPage(): React.JSX.Element { ]) setAutomations(nextAutomations) setRuns(nextRuns) - setSelectedId((current) => current ?? nextAutomations[0]?.id ?? null) + const currentSelectedId = useAppStore.getState().selectedAutomationId + const hasCurrentSelection = nextAutomations.some( + (automation) => automation.id === currentSelectedId + ) + if (!hasCurrentSelection) { + setSelectedId(nextAutomations[0]?.id ?? null) + } } finally { setIsLoading(false) } - }, []) + }, [setSelectedId]) useEffect(() => { void fetchAllWorktrees() @@ -383,10 +411,53 @@ export default function AutomationsPage(): React.JSX.Element { const deleteAutomation = async (automation: Automation): Promise => { await window.api.automations.delete({ id: automation.id }) - setSelectedId(null) + if (useAppStore.getState().selectedAutomationId === automation.id) { + setSelectedId(null) + } await refresh() } + const persistDeleteAutomationPreference = (): void => { + void updateSettings({ skipDeleteAutomationConfirm: true }) + toast.success("We'll skip this confirmation next time.", { + description: 'You can change this in Settings.', + duration: 8000, + action: { + label: 'Open Settings', + onClick: () => { + openSettingsPage() + openSettingsTarget({ + pane: 'general', + repoId: null, + sectionId: 'general-skip-delete-automation-confirm' + }) + } + } + }) + } + + const requestDeleteAutomation = (automation: Automation): void => { + if (settings?.skipDeleteAutomationConfirm) { + void deleteAutomation(automation) + return + } + setDontAskDeleteAgain(false) + setDeleteTarget(automation) + } + + const confirmDeleteAutomation = async (): Promise => { + if (!deleteTarget) { + return + } + if (dontAskDeleteAgain) { + persistDeleteAutomationPreference() + } + const target = deleteTarget + setDeleteTarget(null) + setDontAskDeleteAgain(false) + await deleteAutomation(target) + } + const runNow = async (automation: Automation): Promise => { await window.api.automations.runNow({ id: automation.id }) await refresh() @@ -467,6 +538,81 @@ export default function AutomationsPage(): React.JSX.Element { onSave={() => void saveAutomation()} /> + { + if (open) { + return + } + setDeleteTarget(null) + setDontAskDeleteAgain(false) + }} + > + { + event.preventDefault() + deleteConfirmButtonRef.current?.focus() + }} + > + + Delete Automation + + Delete{' '} + {deleteTarget?.name}{' '} + and its run history. Workspaces created by previous runs are not deleted. + + + {deleteTarget ? ( +
+
{deleteTarget.name}
+
+ {deleteTarget.workspaceMode === 'new_per_run' + ? 'New workspace each run' + : 'Selected workspace'} +
+
+ ) : null} + + + + + +
+
+
@@ -480,40 +626,69 @@ export default function AutomationsPage(): React.JSX.Element { ? 'New workspace each run' : (automationWorktree?.displayName ?? 'Missing workspace') return ( - + + + + + + void runNow(automation)}> + + Run Now + + void openEditDialog(automation)}> + + Edit + + void toggleAutomation(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled ? 'Pause' : 'Resume'} + + + requestDeleteAutomation(automation)} + > + + Delete + + + ) })} {automations.length === 0 ? ( @@ -539,7 +714,7 @@ export default function AutomationsPage(): React.JSX.Element { onOpenRunWorkspace={openRunWorkspace} onEdit={(automation) => void openEditDialog(automation)} onToggle={(automation) => void toggleAutomation(automation)} - onDelete={(automation) => void deleteAutomation(automation)} + onDelete={requestDeleteAutomation} />
diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 5eaf6e1a287..c5a9ae65964 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -266,6 +266,40 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea + +
+ +
+ +

+ Delete automations and their run history without a confirmation dialog. +

+
+ +
+
) : null, matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? ( diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 253355e44de..cdb6dc733a2 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -15,6 +15,11 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ title: 'Skip Delete Worktree Confirmation', description: 'Delete worktrees from the context menu without a confirmation dialog.', keywords: ['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt'] + }, + { + title: 'Skip Delete Automation Confirmation', + description: 'Delete automations without a confirmation dialog.', + keywords: ['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt'] } ] diff --git a/src/renderer/src/components/terminal-pane/agent-status-osc.ts b/src/renderer/src/components/terminal-pane/agent-status-osc.ts new file mode 100644 index 00000000000..1c0c9b2318f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-status-osc.ts @@ -0,0 +1,86 @@ +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { parseAgentStatusPayload } from '../../../../shared/agent-status-types' + +const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' + +export type ProcessedAgentStatusChunk = { + cleanData: string + payloads: ParsedAgentStatusPayload[] +} + +function findAgentStatusTerminator( + data: string, + searchFrom: number +): { index: number; length: 1 | 2 } | null { + const belIndex = data.indexOf('\x07', searchFrom) + const stIndex = data.indexOf('\x1b\\', searchFrom) + if (belIndex === -1 && stIndex === -1) { + return null + } + if (belIndex === -1) { + return { index: stIndex, length: 2 } + } + if (stIndex === -1 || belIndex < stIndex) { + return { index: belIndex, length: 1 } + } + return { index: stIndex, length: 2 } +} + +/** + * Stateful OSC 9999 parser for PTY streams. + * Why: automation background launches need the same agent-status parsing as + * mounted terminal panes, even when no terminal has been rendered yet. + */ +export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk { + const MAX_PENDING = 64 * 1024 + let pending = '' + + return (data: string): ProcessedAgentStatusChunk => { + const combined = pending + data + pending = '' + + const payloads: ParsedAgentStatusPayload[] = [] + let cleanData = '' + let cursor = 0 + + while (cursor < combined.length) { + const start = combined.indexOf(OSC_AGENT_STATUS_PREFIX, cursor) + if (start === -1) { + const tail = combined.slice(cursor) + const prefixLen = OSC_AGENT_STATUS_PREFIX.length + let partialPrefixLen = 0 + for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) { + if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) { + partialPrefixLen = k + break + } + } + if (partialPrefixLen > 0) { + cleanData += tail.slice(0, tail.length - partialPrefixLen) + pending = tail.slice(tail.length - partialPrefixLen) + } else { + cleanData += tail + } + break + } + + cleanData += combined.slice(cursor, start) + const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length + const terminator = findAgentStatusTerminator(combined, payloadStart) + + if (terminator === null) { + const candidate = combined.slice(start) + pending = candidate.length > MAX_PENDING ? '' : candidate + break + } + + const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index)) + if (parsed) { + payloads.push(parsed) + } + cursor = terminator.index + terminator.length + } + + return { cleanData, payloads } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index fe4069405de..98aeff0354c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -19,8 +19,7 @@ import { } from './pty-dispatcher' import type { PtyTransport, IpcPtyTransportOptions, PtyConnectResult } from './pty-dispatcher' import { createBellDetector } from './bell-detector' -import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { parseAgentStatusPayload } from '../../../../shared/agent-status-types' +import { createAgentStatusOscProcessor } from './agent-status-osc' // Re-export public API so existing consumers keep working. export { @@ -38,109 +37,8 @@ export type { } from './pty-dispatcher' export { extractLastOscTitle } from '../../../../shared/agent-detection' -// ─── OSC 9999: agent status reporting ────────────────────────────────────── -// Why OSC 9999: avoids known-used codes (7=cwd, 133=VS Code, 777=Superset, -// 1337=iTerm2, 9001=Warp). Agents report structured status by printing -// printf '\x1b]9999;{"state":"working","prompt":"..."}\x07' -const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' -export type ProcessedAgentStatusChunk = { - cleanData: string - payloads: ParsedAgentStatusPayload[] -} - -function findAgentStatusTerminator( - data: string, - searchFrom: number -): { index: number; length: 1 | 2 } | null { - const belIndex = data.indexOf('\x07', searchFrom) - const stIndex = data.indexOf('\x1b\\', searchFrom) - if (belIndex === -1 && stIndex === -1) { - return null - } - if (belIndex === -1) { - return { index: stIndex, length: 2 } - } - if (stIndex === -1 || belIndex < stIndex) { - return { index: belIndex, length: 1 } - } - return { index: stIndex, length: 2 } -} - -/** - * Stateful OSC 9999 parser for PTY streams. - * Why: the design doc explicitly calls out partial reads across chunks. Regexing - * each chunk independently drops valid status updates when the PTY splits the - * escape sequence mid-payload and can leak raw control bytes into xterm. - */ -export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk { - // Why: cap the pending buffer so a malformed or binary stream containing our - // OSC 9999 prefix without a valid terminator cannot grow memory unbounded. - const MAX_PENDING = 64 * 1024 - let pending = '' - - return (data: string): ProcessedAgentStatusChunk => { - const combined = pending + data - pending = '' - - const payloads: ParsedAgentStatusPayload[] = [] - let cleanData = '' - let cursor = 0 - - while (cursor < combined.length) { - const start = combined.indexOf(OSC_AGENT_STATUS_PREFIX, cursor) - if (start === -1) { - // Why: if the stream ends on a partial copy of the prefix (e.g. "\x1b]9999" - // without the trailing ";"), carrying that tail into `pending` lets the - // next chunk complete the prefix. Without this, the tail would be - // emitted as plain output and the next chunk's valid status update - // would be dropped because its prefix is incomplete on its own. - const tail = combined.slice(cursor) - const prefixLen = OSC_AGENT_STATUS_PREFIX.length - let partialPrefixLen = 0 - for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) { - if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) { - partialPrefixLen = k - break - } - } - if (partialPrefixLen > 0) { - cleanData += tail.slice(0, tail.length - partialPrefixLen) - pending = tail.slice(tail.length - partialPrefixLen) - } else { - cleanData += tail - } - break - } - - cleanData += combined.slice(cursor, start) - const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length - const terminator = findAgentStatusTerminator(combined, payloadStart) - - if (terminator === null) { - const candidate = combined.slice(start) - // Why: drop the unterminated OSC entirely when it overflows MAX_PENDING, - // instead of flushing it to xterm. xterm.js would treat a lone - // "\x1b]9999;..." as an open string state and could swallow later - // output until it sees a BEL/ST terminator. Bounding the buffer is the - // goal; leaking corrupt escape sequences would be worse than the - // dropped payload. - pending = candidate.length > MAX_PENDING ? '' : candidate - break - } - - const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index)) - if (parsed) { - payloads.push(parsed) - } - cursor = terminator.index + terminator.length - } - - return { cleanData, payloads } - } -} - // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index ef9a6223d29..70c1d626957 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session' import { useAppStore } from '@/store' import type { AutomationDispatchResult } from '../../../shared/automations-types' +import { FIRST_PANE_ID } from '../../../shared/pane-key' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' @@ -110,20 +111,69 @@ export function useAutomationDispatchEvents(): void { let dispatchMarked = false let pendingExitCode: number | null = null - const markExitResult = (code: number): Promise => - markDispatchResult({ + let pendingDone = false + let completionMarked = false + let unsubscribeAgentStatus = (): void => {} + const markCompletionResult = async (): Promise => { + if (completionMarked) { + return + } + completionMarked = true + unsubscribeAgentStatus() + await markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: worktree.id, + error: null + }) + } + const markExitResult = (code: number): Promise => { + unsubscribeAgentStatus() + return markDispatchResult({ runId: run.id, status: code === 0 ? 'completed' : 'dispatch_failed', workspaceId: worktree.id, error: code === 0 ? null : `Automation process exited with code ${code}.` }) + } + const handleAgentDone = (): void => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingDone = true + return + } + void markCompletionResult() + } + const observeAgentStatus = (tabId: string): void => { + const paneKey = `${tabId}:${FIRST_PANE_ID}` + const checkCurrentStatus = (): void => { + if (useAppStore.getState().agentStatusByPaneKey[paneKey]?.state === 'done') { + 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 result = await launchAgentBackgroundSession({ agent: automation.agentId, worktreeId: worktree.id, prompt: automation.prompt, launchSource: 'unknown', title: run.title, + onAgentStatus: (payload) => { + if (payload.state !== 'done') { + return + } + handleAgentDone() + }, onExit: (_ptyId, code) => { + if (completionMarked) { + return + } if (!dispatchMarked) { pendingExitCode = code return @@ -134,16 +184,24 @@ export function useAutomationDispatchEvents(): void { if (!result) { throw new Error('Unable to build an agent launch plan.') } - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - terminalSessionId: result.tabId, - error: null - }) - dispatchMarked = true - if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) + observeAgentStatus(result.tabId) + try { + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + terminalSessionId: result.tabId, + error: null + }) + dispatchMarked = true + if (pendingDone) { + await markCompletionResult() + } else if (pendingExitCode !== null) { + await markExitResult(pendingExitCode) + } + } catch (error) { + unsubscribeAgentStatus() + throw error } const currentState = useAppStore.getState() // Why: Run Now and scheduled dispatches should create workspaces/tabs in diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 26db8808c94..aacf55857db 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -6,6 +6,7 @@ const mockSetTabCustomTitle = vi.fn() const mockUpdateTabPtyId = vi.fn() const mockCloseTab = vi.fn() const mockRegisterEagerPtyBuffer = vi.fn() +const mockSubscribeToPtyData = vi.fn() const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() @@ -19,7 +20,8 @@ const state = { setTabCustomTitle: mockSetTabCustomTitle, updateTabPtyId: mockUpdateTabPtyId, closeTab: mockCloseTab, - clearTabPtyId: vi.fn() + clearTabPtyId: vi.fn(), + setAgentStatus: vi.fn() } vi.mock('@/store', () => ({ @@ -39,6 +41,7 @@ vi.mock('@/lib/agent-paste-draft', () => ({ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, + subscribeToPtyData: mockSubscribeToPtyData, subscribeToPtyExit: mockSubscribeToPtyExit })) @@ -47,6 +50,7 @@ describe('launchAgentBackgroundSession', () => { vi.clearAllMocks() mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' }) mockSpawn.mockResolvedValue({ id: 'pty-1' }) + mockSubscribeToPtyData.mockReturnValue(vi.fn()) mockSubscribeToPtyExit.mockReturnValue(vi.fn()) vi.stubGlobal('window', { api: { @@ -72,6 +76,11 @@ describe('launchAgentBackgroundSession', () => { expect.objectContaining({ cwd: '/repo/worktree', command: "claude 'run the automation'", + env: { + ORCA_PANE_KEY: 'tab-1:1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1' + }, connectionId: null, worktreeId: 'wt-1', tabId: 'tab-1', @@ -81,10 +90,35 @@ describe('launchAgentBackgroundSession', () => { expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit') expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function)) + expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(mockSubscribeToPtyExit).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'pty-1' }) }) + it('parses agent status from hidden PTY output', async () => { + const onAgentStatus = vi.fn() + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + onAgentStatus + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + it('uses a sidecar exit watcher so completion survives terminal attachment', async () => { const unsubscribe = vi.fn() mockSubscribeToPtyExit.mockReturnValue(unsubscribe) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 86595d8e4f4..d0ae944bb18 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -7,10 +7,14 @@ import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' +import { FIRST_PANE_ID } from '../../../shared/pane-key' import { registerEagerPtyBuffer, + subscribeToPtyData, subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' +import { createAgentStatusOscProcessor } from '@/components/terminal-pane/agent-status-osc' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' export type LaunchAgentBackgroundSessionArgs = { agent: TuiAgent @@ -19,6 +23,7 @@ export type LaunchAgentBackgroundSessionArgs = { launchSource?: LaunchSource title?: string onExit?: (ptyId: string, code: number) => void + onAgentStatus?: (payload: ParsedAgentStatusPayload) => void } export type LaunchAgentBackgroundSessionResult = { @@ -30,7 +35,7 @@ export type LaunchAgentBackgroundSessionResult = { export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs ): Promise { - const { agent, worktreeId, prompt, launchSource, title, onExit } = args + const { agent, worktreeId, prompt, launchSource, title, onExit, onAgentStatus } = args const store = useAppStore.getState() const worktree = store.allWorktrees().find((entry) => entry.id === worktreeId) const repo = worktree ? store.repos.find((entry) => entry.id === worktree.repoId) : null @@ -72,6 +77,15 @@ export async function launchAgentBackgroundSession( if (title) { store.setTabCustomTitle(tab.id, title) } + const paneKey = `${tab.id}:${FIRST_PANE_ID}` + // Why: agent hook callbacks are keyed by pane, and background automation + // tabs never mount a TerminalPane to inject this env for us. + const paneEnv = { + ...startupPlan.env, + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tab.id, + ORCA_WORKTREE_ID: worktreeId + } let result: Awaited> try { result = await window.api.pty.spawn({ @@ -79,7 +93,7 @@ export async function launchAgentBackgroundSession( rows: 40, cwd: worktree.path, command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), + env: paneEnv, connectionId: repo?.connectionId ?? null, worktreeId, tabId: tab.id, @@ -97,16 +111,26 @@ export async function launchAgentBackgroundSession( store.updateTabPtyId(tab.id, result.id) let exitHandled = false let unsubscribeExit = (): void => {} + let unsubscribeData = (): void => {} const handleExit = (ptyId: string, code: number): void => { if (exitHandled) { return } exitHandled = true unsubscribeExit() + unsubscribeData() useAppStore.getState().clearTabPtyId(tab.id, ptyId) onExit?.(ptyId, code) } registerEagerPtyBuffer(result.id, handleExit) + const processAgentStatus = createAgentStatusOscProcessor() + unsubscribeData = subscribeToPtyData(result.id, (data) => { + const processed = processAgentStatus(data) + for (const payload of processed.payloads) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + onAgentStatus?.(payload) + } + }) // Why: opening the workspace attaches a real terminal transport and disposes // the eager exit handler. This sidecar keeps automation completion tracking // alive regardless of whether the tab is hidden or mounted. diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index aabef5aa4ec..426709e5b56 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -230,6 +230,8 @@ export type UISlice = { closeTaskPage: () => void openActivityPage: () => void closeActivityPage: () => void + selectedAutomationId: string | null + setSelectedAutomationId: (id: string | null) => void openAutomationsPage: () => void closeAutomationsPage: () => void setNewWorkspaceDraft: (draft: NonNullable) => void @@ -513,6 +515,8 @@ export const createUISlice: StateCreator = (set, get) set((state) => ({ activeView: state.previousViewBeforeActivity })), + selectedAutomationId: null, + setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), openAutomationsPage: () => set((state) => ({ activeView: 'automations', diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7d2fb1b6f6e..e2745b13975 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -221,6 +221,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, + skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, diff --git a/src/shared/types.ts b/src/shared/types.ts index 6198ad888ad..29bd2de650e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1237,6 +1237,10 @@ export type GlobalSettings = { * again" checkbox inside it or from the General settings pane. We keep this * defaulted to false so first-time behavior stays safe. */ skipDeleteWorktreeConfirm: boolean + /** Why: deleting an automation also deletes its run history. Keep this + * separate from worktree deletion so skipping one destructive confirmation + * does not silently skip the other. */ + skipDeleteAutomationConfirm: boolean /** Default preset in the new-workspace GitHub task view. */ defaultTaskViewPreset: TaskViewPresetId /** Why: persists the user's last-used task source so the Tasks page