mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
@@ -89,6 +89,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultTaskSource: 'github',
|
||||
defaultRepoSelection: null,
|
||||
|
||||
@@ -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'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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -221,6 +221,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultTaskSource: 'github',
|
||||
defaultRepoSelection: null,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user