Add local automations workflow

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-14 01:54:37 -04:00
co-authored by Orca
parent ecae7d1a0c
commit b9ae19716d
13 changed files with 483 additions and 158 deletions
@@ -89,6 +89,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
+1
View File
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
@@ -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<Automation[]>([])
const [runs, setRuns] = useState<AutomationRun[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
const [relativeNow, setRelativeNow] = useState(Date.now())
const [draftAtOpen, setDraftAtOpen] = useState<AutomationDraft | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null)
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
const editRequestRef = useRef(0)
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
const [draft, setDraft] = useState<AutomationDraft>({
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<void> => {
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<void> => {
if (!deleteTarget) {
return
}
if (dontAskDeleteAgain) {
persistDeleteAutomationPreference()
}
const target = deleteTarget
setDeleteTarget(null)
setDontAskDeleteAgain(false)
await deleteAutomation(target)
}
const runNow = async (automation: Automation): Promise<void> => {
await window.api.automations.runNow({ id: automation.id })
await refresh()
@@ -467,6 +538,81 @@ export default function AutomationsPage(): React.JSX.Element {
onSave={() => void saveAutomation()}
/>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (open) {
return
}
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}}
>
<DialogContent
className="max-w-md"
onOpenAutoFocus={(event) => {
event.preventDefault()
deleteConfirmButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">Delete Automation</DialogTitle>
<DialogDescription className="text-xs">
Delete{' '}
<span className="break-all font-medium text-foreground">{deleteTarget?.name}</span>{' '}
and its run history. Workspaces created by previous runs are not deleted.
</DialogDescription>
</DialogHeader>
{deleteTarget ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">{deleteTarget.name}</div>
<div className="mt-1 text-muted-foreground">
{deleteTarget.workspaceMode === 'new_per_run'
? 'New workspace each run'
: 'Selected workspace'}
</div>
</div>
) : null}
<button
type="button"
role="checkbox"
aria-checked={dontAskDeleteAgain}
onClick={() => setDontAskDeleteAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskDeleteAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskDeleteAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}}
>
Cancel
</Button>
<Button
ref={deleteConfirmButtonRef}
variant="destructive"
onClick={() => void confirmDeleteAutomation()}
>
<Trash2 className="size-4" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(280px,360px)_1fr] overflow-hidden border-t border-border/50">
<section className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20">
<div className="min-h-0 flex-1 overflow-auto p-2">
@@ -480,40 +626,69 @@ export default function AutomationsPage(): React.JSX.Element {
? 'New workspace each run'
: (automationWorktree?.displayName ?? 'Missing workspace')
return (
<button
key={automation.id}
type="button"
onClick={() => setSelectedId(automation.id)}
className={cn(
'mb-1 flex w-full flex-col gap-1 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selected?.id === automation.id
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="font-medium">{automation.name}</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{automationRepo ? (
<RepoDotLabel
name={automationRepo.displayName}
color={automationRepo.badgeColor}
dotClassName="size-1.5"
/>
) : (
<span>Unknown project</span>
)}
<span className="shrink-0">/</span>
<span className="truncate">{workspaceLabel}</span>
</span>
<span className="text-xs text-muted-foreground">
{automation.enabled
? `Next run ${formatAutomationDateTimeWithRelative(
automation.nextRunAt,
relativeNow
)}`
: 'Paused'}
</span>
</button>
<ContextMenu key={automation.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => setSelectedId(automation.id)}
className={cn(
'mb-1 flex w-full flex-col gap-1 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selected?.id === automation.id
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="font-medium">{automation.name}</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{automationRepo ? (
<RepoDotLabel
name={automationRepo.displayName}
color={automationRepo.badgeColor}
dotClassName="size-1.5"
/>
) : (
<span>Unknown project</span>
)}
<span className="shrink-0">/</span>
<span className="truncate">{workspaceLabel}</span>
</span>
<span className="text-xs text-muted-foreground">
{automation.enabled
? `Next run ${formatAutomationDateTimeWithRelative(
automation.nextRunAt,
relativeNow
)}`
: 'Paused'}
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onSelect={() => void runNow(automation)}>
<Play className="size-3.5" />
Run Now
</ContextMenuItem>
<ContextMenuItem onSelect={() => void openEditDialog(automation)}>
<Pencil className="size-3.5" />
Edit
</ContextMenuItem>
<ContextMenuItem onSelect={() => void toggleAutomation(automation)}>
{automation.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
{automation.enabled ? 'Pause' : 'Resume'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => requestDeleteAutomation(automation)}
>
<Trash2 className="size-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
{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}
/>
</section>
</div>
@@ -266,6 +266,40 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
</button>
</SearchableSetting>
</div>
<div id="general-skip-delete-automation-confirm" className="scroll-mt-6">
<SearchableSetting
title="Skip Delete Automation Confirmation"
description="Delete automations without a confirmation dialog."
keywords={['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Skip Delete Automation Confirmation</Label>
<p className="text-xs text-muted-foreground">
Delete automations and their run history without a confirmation dialog.
</p>
</div>
<button
role="switch"
aria-checked={settings.skipDeleteAutomationConfirm}
onClick={() =>
updateSettings({
skipDeleteAutomationConfirm: !settings.skipDeleteAutomationConfirm
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.skipDeleteAutomationConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.skipDeleteAutomationConfirm ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? (
@@ -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']
}
]
@@ -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 }
}
}
@@ -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.
@@ -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<void> =>
markDispatchResult({
let pendingDone = false
let completionMarked = false
let unsubscribeAgentStatus = (): void => {}
const markCompletionResult = async (): Promise<void> => {
if (completionMarked) {
return
}
completionMarked = true
unsubscribeAgentStatus()
await markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: worktree.id,
error: null
})
}
const markExitResult = (code: number): Promise<void> => {
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
@@ -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)
@@ -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<LaunchAgentBackgroundSessionResult | null> {
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<ReturnType<typeof window.api.pty.spawn>>
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.
+4
View File
@@ -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<UISlice['newWorkspaceDraft']>) => void
@@ -513,6 +515,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set((state) => ({
activeView: state.previousViewBeforeActivity
})),
selectedAutomationId: null,
setSelectedAutomationId: (id) => set({ selectedAutomationId: id }),
openAutomationsPage: () =>
set((state) => ({
activeView: 'automations',
+1
View File
@@ -221,6 +221,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
+4
View File
@@ -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