feat: preserve editable prompts through workspace creation

This commit is contained in:
Neil
2026-09-05 11:56:45 -07:00
parent 798cda1cd5
commit 8e63ae4ada
39 changed files with 2571 additions and 49 deletions
@@ -37,6 +37,7 @@ const TARGET_ID = 'remote-1'
const REMOTE_PATH = '/srv/app'
type RuntimeInternals = {
buildStartupForDraft: (...args: unknown[]) => Promise<unknown>
resolveRepoSelector: (selector: string) => Promise<unknown>
createManagedRemoteWorktree: (repo: unknown, args: unknown) => Promise<unknown>
resolveLineageForWorktreeCreate: (input: unknown) => Promise<unknown>
@@ -152,4 +153,41 @@ describe('createManagedWorktree execution-host routing', () => {
expect(trustMocks.local).toHaveBeenCalledWith('codex', '/Users/me/notes')
expect(trustMocks.remote).not.toHaveBeenCalled()
})
it.each([undefined, false, true])(
'applies startupActivate=%s after host draft construction',
async (startupActivate) => {
const { runtime, createRemote } = makeRuntime({
id: 'repo-remote',
path: REMOTE_PATH,
kind: 'git',
connectionId: TARGET_ID
})
const startup = { command: 'host-quoted-agent', env: { TOKEN: 'fixture' } }
vi.spyOn(runtime as unknown as RuntimeInternals, 'buildStartupForDraft').mockResolvedValue({
startup,
agent: 'codex',
draftPaste: { text: 'task', agent: 'codex' }
})
await runtime.createManagedWorktree({
repoSelector: 'repo-remote',
name: 'task',
startupDraft: 'task',
...(startupActivate !== undefined ? { startupActivate } : {}),
awaitTerminalProvisioning: true
})
expect(createRemote).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
startup: {
...startup,
...(startupActivate !== undefined ? { activate: startupActivate } : {})
},
startupDraftPaste: { text: 'task', agent: 'codex' },
awaitTerminalProvisioning: true
})
)
expect(startup).not.toHaveProperty('activate')
}
)
})
@@ -50,7 +50,11 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
!args.startup && !agentStartup && args.startupDraft
? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent)
: null
const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup
const resolvedStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup
const effectiveStartup =
resolvedStartup && args.startupActivate !== undefined
? { ...resolvedStartup, activate: args.startupActivate }
: resolvedStartup
const effectiveStartupFollowup = agentStartup?.followup
const effectiveCreatedWithAgent = args.startup
? args.createdWithAgent
@@ -23,6 +23,27 @@ describe('buildManagedWorktreeCreateArgs', () => {
).toMatchObject({ startup: { command, activate: false }, activate: false })
})
it('preserves caller completion flags for a host-built draft startup', () => {
expect(
build({
repo: 'repo',
startupDraft: 'task',
startupActivate: false,
awaitTerminalProvisioning: true
})
).toMatchObject({
startupDraft: 'task',
startupActivate: false,
awaitTerminalProvisioning: true
})
})
it('leaves provisioning and startup override absent for legacy callers', () => {
const args = build({ repo: 'repo', startupDraft: 'task' })
expect(args).not.toHaveProperty('startupActivate')
expect(args).not.toHaveProperty('awaitTerminalProvisioning')
})
it('keeps legacy empty-command and absent-command requests free of startup', () => {
expect(build({ repo: 'repo', startupCommand: '' }).startup).toBeUndefined()
expect(build({ repo: 'repo', startupActivate: false }).startup).toBeUndefined()
@@ -47,6 +47,10 @@ export function buildManagedWorktreeCreateArgs(
pushTarget: params.pushTarget,
runHooks: params.runHooks === true,
activate: params.activate === true,
...(params.startupActivate !== undefined ? { startupActivate: params.startupActivate } : {}),
...(params.awaitTerminalProvisioning !== undefined
? { awaitTerminalProvisioning: params.awaitTerminalProvisioning }
: {}),
// Why: create-activation is the caller's own view intent; without this a paired
// client's create dragged every other connected client and the host with it.
// Why 'runtime' only: a phone has no terminal-provisioning renderer, so when it
@@ -105,6 +105,7 @@ export const WorktreeCreate = z
// Clients that can't quote for the host shell send `startupAgent` instead.
startupCommand: OptionalPlainString,
startupActivate: z.boolean().optional(),
awaitTerminalProvisioning: z.boolean().optional(),
startupEnv: z.record(z.string(), z.string()).optional(),
startupLaunchConfig: sleepingAgentLaunchConfigSchema,
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
@@ -124,7 +124,8 @@ export async function createRuntimeFolderWorktree(args: {
path: worktree.path,
branch: worktree.branch
})
const shouldActivate = request.activate === true || request.runHooks === true
const shouldActivate =
args.startup?.activate !== false && (request.activate === true || request.runHooks === true)
let warning: string | undefined
let didSpawnStartup = false
let startupTerminal: CreateWorktreeResult['startupTerminal']
@@ -136,6 +137,7 @@ export async function createRuntimeFolderWorktree(args: {
}
const terminal = await deps.createTerminal(`id:${worktree.id}`, {
command: args.startup.command,
...(args.startup.activate === false ? { activate: false } : {}),
env: args.startup.env,
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
import type { RuntimeStore } from './runtime-store-contract'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
function fixture() {
const createTerminal = vi.fn(async (_selector: string, _options: TerminalCreateOptions) => ({
handle: 'original-agent',
tabId: 'agent-tab',
worktreeId: 'workspace',
title: null
}))
const activate = vi.fn()
const deps = {
store: {
getSettings: () => ({ workspaceDir: '/workspaces' }),
getProjectHostSetups: () => [],
setWorktreeMeta: (_id: string, meta: object) => meta
} as unknown as RuntimeStore,
ptySpawnAvailable: true,
createTerminal,
markTrusted: vi.fn(async () => {}),
pasteDraft: vi.fn(),
sendFollowup: vi.fn(),
invalidateResolvedWorktrees: vi.fn(),
notifyWorktreesChanged: vi.fn(),
emitCreated: vi.fn(),
activate
}
const run = (activateStartup?: boolean, connectionId?: string) =>
createRuntimeFolderWorktree({
request: { repoSelector: 'repo', name: 'draft', runHooks: true, activate: true },
repo: {
id: 'repo',
path: '/notes',
displayName: 'Notes',
badgeColor: '',
addedAt: 0,
kind: 'folder',
...(connectionId ? { connectionId } : {})
},
startup: {
command: 'agent',
...(activateStartup !== undefined ? { activate: activateStartup } : {})
},
createdWithAgent: 'codex',
deps
})
return { ...deps, run }
}
describe('folder workspace startup navigation', () => {
it.each([undefined, 'ssh-host'])(
'keeps background agent materialized without selecting on %s',
async (connectionId) => {
const f = fixture()
const result = await f.run(false, connectionId)
expect(f.activate).not.toHaveBeenCalled()
expect(f.createTerminal).toHaveBeenCalledExactlyOnceWith(
expect.any(String),
expect.objectContaining({
command: 'agent',
activate: false,
surfaceOwner: false
})
)
expect(result.startupTerminal).toMatchObject({ spawned: true, handle: 'original-agent' })
}
)
it.each([undefined, true])(
'preserves existing activation with startupActivate=%s',
async (activate) => {
const f = fixture()
await f.run(activate)
expect(f.activate).toHaveBeenCalledOnce()
expect(f.createTerminal.mock.calls[0]?.[1]).not.toHaveProperty('surfaceOwner')
}
)
})
@@ -55,6 +55,7 @@ export type RuntimeManagedWorktreeCreateArgs = {
automationProvenance?: AutomationWorkspaceProvenance
cliProvenance?: CliWorkspaceProvenance
creatorProvenance?: Worktree['creatorProvenance']
startupActivate?: boolean
startup?: WorktreeStartupLaunch
startupDraft?: string
startupDraftPaste?: WorktreeStartupDraftPaste
@@ -119,6 +119,39 @@ describe.each(['local', 'remote'] as const)('%s background startup', (host) => {
}
)
it('awaits setup provisioning and preserves failed setup for renderer recovery', async () => {
const f = fixture(host, { worktree, setup, defaultTabs })
let finishProvisioning!: (result: {
setupSpawned: boolean
setupTerminalHandle: string
}) => void
f.provision.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProvisioning = resolve
})
)
let settled = false
const creating = f
.run({ startup: { command: 'agent', activate: false }, awaitTerminalProvisioning: true })
.then((result) => {
settled = true
return result
})
await vi.waitFor(() => expect(f.provision).toHaveBeenCalledOnce())
expect(settled).toBe(false)
finishProvisioning({ setupSpawned: false, setupTerminalHandle: '' })
const result = await creating
expect(
'returnedSetup' in result
? result.returnedSetup
: 'setup' in result
? result.setup
: undefined
).toEqual(setup)
expect(f.activate).not.toHaveBeenCalled()
})
it('preserves hook activation when the caller supplies no startup', async () => {
const f = fixture(host, { worktree, setup, defaultTabs })
await f.run({ runHooks: true })
@@ -3,6 +3,7 @@ import { PreloadedTerminal as Terminal } from '@/lib/terminal-component-loader'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import Sidebar from '../components/Sidebar'
import { CreationDraftSurface } from '../components/worktree-creation/CreationDraftSurface'
import RightSidebar from '../components/right-sidebar'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'
@@ -178,41 +179,44 @@ export function AppWorkspaceShell(props: {
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{layout.shouldMountTerminalWorkbench ? (
<TerminalWorkbenchContainer isVisible={layout.terminalWorkbenchVisible}>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</TerminalWorkbenchContainer>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${layout.activeView}`}
surface="page"
resetKey={layout.activeView}
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
description={translate(
'auto.App.03a14f6b5b',
'Retry the page or navigate to another Orca surface.'
)}
>
<ActivePage layout={layout} />
</RecoverableRenderErrorBoundary>
</Suspense>
<div className="relative flex flex-1 min-w-0 min-h-0 flex-col">
{layout.shouldMountTerminalWorkbench ? (
<TerminalWorkbenchContainer isVisible={layout.terminalWorkbenchVisible}>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</TerminalWorkbenchContainer>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${layout.activeView}`}
surface="page"
resetKey={layout.activeView}
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
description={translate(
'auto.App.03a14f6b5b',
'Retry the page or navigate to another Orca surface.'
)}
>
<ActivePage layout={layout} />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
<CreationDraftSurface />
</div>
{floatingWorkspace.showToggleButton ? (
<FloatingTerminalToggleButton
@@ -42,7 +42,7 @@ export function isLinuxUserAgent(
return !isMacUserAgent(userAgent) && !isWindowsUserAgent(userAgent) && userAgent.includes('Linux')
}
function shouldPreserveEditableFocus(element: Element | null): boolean {
export function shouldPreserveEditableFocus(element: Element | null): boolean {
if (!(element instanceof HTMLElement)) {
return false
}
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const replay = vi.hoisted(() => vi.fn())
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ repos: [] }) } }))
vi.mock('./layout-serialization', () => ({
replayTerminalLayout: replay,
restoreScrollbackBuffers: vi.fn()
}))
vi.mock('./expand-collapse', () => ({ applyExpandedLayoutTo: vi.fn() }))
vi.mock('./terminal-pane-lifecycle-primitives', () => ({
replayLayoutWithOneShotParkIntent: (_deps: unknown, restore: () => unknown) => restore(),
mapRestoredPaneTitlesByPaneId: () => ({})
}))
import { restoreTerminalPaneLayout } from './terminal-pane-layout-restore'
type RestoreArgs = Parameters<typeof restoreTerminalPaneLayout>[0]
function restore(isActive: boolean) {
const manager = { setActivePane: vi.fn(), getPanes: () => [{ id: 7 }] }
const layout = { activeLeafId: 'leaf' }
const result = restoreTerminalPaneLayout({
manager: manager as unknown as RestoreArgs['manager'],
deps: {
initialLayoutRef: { current: layout },
tabId: 'tab',
worktreeId: 'workspace',
isActive,
managerRef: { current: manager },
replayingPanesRef: { current: new Set() },
setExpandedPane: vi.fn()
} as unknown as RestoreArgs['deps'],
refs: { restoredViewportBlankingPanesRef: { current: new Set() } } as RestoreArgs['refs'],
ptyDeps: {} as RestoreArgs['ptyDeps'],
initialLayoutHadBuffers: false
})
expect(result.get('leaf')).toBe(7)
return { manager, layout }
}
beforeEach(() => {
vi.clearAllMocks()
replay.mockReturnValue(new Map([['leaf', 7]]))
})
afterEach(() => {
document.body.replaceChildren()
})
describe('terminal layout restoration focus ownership', () => {
it.each(['textarea', 'input', 'select'] as const)(
'preserves a focused %s when the active workspace terminal appears',
(tag) => {
const editor = document.createElement(tag)
document.body.append(editor)
editor.focus()
expect(document.activeElement).toBe(editor)
const { manager, layout } = restore(true)
expect(replay).toHaveBeenCalledExactlyOnceWith(manager, layout, false)
expect(manager.setActivePane).toHaveBeenCalledExactlyOnceWith(7, { focus: false })
expect(document.activeElement).toBe(editor)
}
)
it('preserves a focused contenteditable editor', () => {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.tabIndex = 0
document.body.append(editor)
editor.focus()
expect(document.activeElement).toBe(editor)
const { manager, layout } = restore(true)
expect(replay).toHaveBeenCalledExactlyOnceWith(manager, layout, false)
expect(manager.setActivePane).toHaveBeenCalledExactlyOnceWith(7, { focus: false })
})
it('focuses the active terminal when the body holds focus', () => {
expect(document.activeElement).toBe(document.body)
const { manager, layout } = restore(true)
expect(replay).toHaveBeenCalledExactlyOnceWith(manager, layout, true)
expect(manager.setActivePane).toHaveBeenCalledExactlyOnceWith(7, { focus: true })
})
it('allows terminal focus when xterm owns its editable input', () => {
const terminal = document.createElement('div')
terminal.className = 'xterm'
const input = document.createElement('textarea')
input.className = 'xterm-helper-textarea'
terminal.append(input)
document.body.append(terminal)
input.focus()
expect(document.activeElement).toBe(input)
const { manager, layout } = restore(true)
expect(replay).toHaveBeenCalledExactlyOnceWith(manager, layout, true)
expect(manager.setActivePane).toHaveBeenCalledExactlyOnceWith(7, { focus: true })
})
it('never requests focus for an inactive terminal', () => {
expect(document.activeElement).toBe(document.body)
const { manager, layout } = restore(false)
expect(replay).toHaveBeenCalledExactlyOnceWith(manager, layout, false)
expect(manager.setActivePane).toHaveBeenCalledExactlyOnceWith(7, { focus: false })
})
})
@@ -1,5 +1,6 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { useAppStore } from '@/store'
import { shouldPreserveEditableFocus } from './pane-helpers'
import { applyExpandedLayoutTo } from './expand-collapse'
import { replayTerminalLayout, restoreScrollbackBuffers } from './layout-serialization'
import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
@@ -20,8 +21,11 @@ export function restoreTerminalPaneLayout(args: {
}): Map<string, number> {
const { manager, deps, refs, ptyDeps, initialLayoutHadBuffers } = args
const { initialLayoutRef, tabId, worktreeId, isActive, managerRef } = deps
const shouldFocus =
isActive &&
!shouldPreserveEditableFocus(typeof document === 'undefined' ? null : document.activeElement)
const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () =>
replayTerminalLayout(manager, initialLayoutRef.current, isActive)
replayTerminalLayout(manager, initialLayoutRef.current, shouldFocus)
)
const restoredBuffers = initialLayoutRef.current.buffersByLeafId
restoreScrollbackBuffers(
@@ -65,7 +69,7 @@ export function restoreTerminalPaneLayout(args: {
manager.getPanes()[0]?.id ??
null
if (restoredActivePaneId !== null) {
manager.setActivePane(restoredActivePaneId, { focus: isActive })
manager.setActivePane(restoredActivePaneId, { focus: shouldFocus })
}
const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
@@ -0,0 +1,222 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { translate } from '@/i18n/i18n'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { submitCreationDraft } from '@/lib/workspace-creation-drafts/creation-draft-submit'
import {
useCreationDraftSession,
editCreationDraft,
discardCreationDraft,
flushCreationDraft,
saveCreationDraftCopy
} from '@/lib/workspace-creation-drafts/creation-draft-session'
import {
CREATION_DRAFT_LIMIT,
CREATION_DRAFT_TEXT_BYTES,
type CreationDraftInput
} from '@/lib/workspace-creation-drafts/creation-draft-record'
export function CreationDraftEditor({
initial,
editorRef
}: {
initial: CreationDraftInput
editorRef: React.RefObject<HTMLTextAreaElement | null>
}): React.JSX.Element {
const entry = useCreationDraftSession((state) => state.entries[initial.id])
const buffer = entry?.buffer ?? initial
const [actionPending, setActionPending] = useState(false)
const [sendError, setSendError] = useState(false)
const [inputError, setInputError] = useState<string | null>(null)
const atCapacity = useCreationDraftSession(
(state) =>
!state.entries[initial.id] && Object.keys(state.entries).length >= CREATION_DRAFT_LIMIT
)
const send = async (): Promise<void> => {
setActionPending(true)
setSendError(false)
try {
const result = await submitCreationDraft(buffer.id)
setSendError(result.status !== 'delivered' && result.status !== 'uncertain')
} catch {
setSendError(true)
} finally {
setActionPending(false)
}
}
const copy = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(buffer.text)
} catch {
toast.error(translate('creationDraft.copyFailed', 'Could not copy the draft.'))
}
}
const saveCopy = async (): Promise<void> => {
setActionPending(true)
try {
await saveCreationDraftCopy(buffer.id)
toast.success(
translate(
'creationDraft.copySaved',
'Saved separately. Use Copy draft to bring this text to your agent.'
)
)
} catch {
toast.error(
translate(
'creationDraft.copySaveFailed',
'Could not save a separate draft. Your text is still here.'
)
)
} finally {
setActionPending(false)
}
}
const discard = async (): Promise<void> => {
setActionPending(true)
try {
await discardCreationDraft(buffer.id)
} catch {
toast.error(
translate(
'creationDraft.discardFailed',
'Could not discard the draft. Your text is still here.'
)
)
} finally {
setActionPending(false)
}
}
return (
<div className="mt-2 flex flex-col gap-2">
<Textarea
ref={editorRef}
autoFocus
rows={4}
className="max-h-64 resize-y font-mono text-sm"
disabled={actionPending || atCapacity}
aria-label={translate('creationDraft.promptLabel', 'Workspace prompt draft')}
placeholder={translate(
'creationDraft.promptPlaceholder',
'Start your prompt while the workspace prepares…'
)}
value={buffer.text}
onChange={(event) => {
if (new TextEncoder().encode(event.target.value).byteLength > CREATION_DRAFT_TEXT_BYTES) {
setInputError(
translate(
'creationDraft.tooLong',
'That text exceeds the 64 KB draft limit. Your previous text is kept.'
)
)
return
}
setInputError(null)
editCreationDraft({
...buffer,
text: event.target.value,
delivery: buffer.delivery?.state === 'delivered' ? undefined : buffer.delivery,
updatedAt: Date.now()
})
}}
/>
{atCapacity || inputError ? (
<p role="alert" className="text-xs text-destructive">
{atCapacity
? translate(
'creationDraft.full',
'All 64 draft slots are in use. Discard a saved draft to start another.'
)
: inputError}
</p>
) : null}
{buffer.delivery ? (
<p role="status" className="text-xs text-muted-foreground">
{buffer.delivery.state === 'delivered'
? translate('creationDraft.delivered', 'Sent to the terminal. Your draft is kept here.')
: translate(
'creationDraft.uncertain',
'Delivery is unconfirmed. Check the terminal before sending again. Your draft is kept here.'
)}
</p>
) : null}
{sendError ? (
<p role="alert" className="text-xs text-destructive">
{translate(
'creationDraft.sendFailed',
'The original agent is not ready for this draft. Your text is still here; you can copy it or try again.'
)}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<span
role="status"
className={`mr-auto text-xs ${entry?.error ? 'text-destructive' : 'text-muted-foreground'}`}
>
{entry?.conflict
? translate(
'creationDraft.conflict',
'This draft changed in another window. Save a separate copy to keep your text.'
)
: entry?.error
? translate(
'creationDraft.saveFailed',
'Not saved. Keep this window open or copy your text.'
)
: entry && entry.savedVersion !== entry.editVersion
? translate('creationDraft.saving', 'Saving…')
: entry
? translate('creationDraft.saved', 'Saved on this device')
: ''}
</span>
{entry?.conflict ? (
<Button
size="xs"
variant="outline"
disabled={actionPending}
onClick={() => void saveCopy()}
>
{translate('creationDraft.saveCopy', 'Save a copy')}
</Button>
) : entry?.error ? (
<Button size="xs" variant="outline" onClick={() => void flushCreationDraft(buffer.id)}>
{translate('creationDraft.retry', 'Retry')}
</Button>
) : null}
{buffer.target ? (
<Button
size="xs"
variant="ghost"
onClick={() => activateAndRevealWorkspace(buffer.target!.worktreeId)}
>
{translate('creationDraft.openWorkspace', 'Open workspace')}
</Button>
) : null}
<Button
size="xs"
variant="ghost"
onClick={() => void discard()}
disabled={!entry || actionPending}
>
{translate('creationDraft.discard', 'Discard')}
</Button>
{buffer.target?.terminalHandle && buffer.executionHostId === 'local' ? (
<Button
size="xs"
disabled={!buffer.text.trim() || actionPending || Boolean(buffer.delivery)}
onClick={() => void send()}
>
{actionPending
? translate('creationDraft.sending', 'Sending…')
: translate('creationDraft.send', 'Send')}
</Button>
) : null}
<Button size="xs" variant="outline" disabled={!buffer.text} onClick={() => void copy()}>
{translate('creationDraft.copy', 'Copy draft')}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react'
import { CreationDraftEditor } from './CreationDraftEditor'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
useCreationDraftSession,
loadCreationDrafts
} from '@/lib/workspace-creation-drafts/creation-draft-session'
import type { CreationDraftInput } from '@/lib/workspace-creation-drafts/creation-draft-record'
export function CreationDraftSurface(): React.JSX.Element | null {
const { activeView, activeWorktreeId, pending, repos } = useAppStore(
useShallow((state) => ({
activeView: state.activeView,
activeWorktreeId: state.activeWorktreeId,
pending: state.activePendingCreationId
? state.pendingWorktreeCreations[state.activePendingCreationId]
: undefined,
repos: state.repos
}))
)
const editorRef = useRef<HTMLTextAreaElement>(null)
const recoveringDraft = useRef(false)
const session = useCreationDraftSession()
const [hiddenId, setHiddenId] = useState<string | null>(null)
useEffect(() => {
void loadCreationDrafts()
}, [])
if (activeView !== 'terminal') {
return null
}
const drafts = Object.values(session.entries)
const eligiblePending =
pending?.request.agent && pending.request.agentLaunchRoute !== 'structured-native-chat'
? pending
: undefined
const selected = session.viewedDraftId ? session.entries[session.viewedDraftId] : undefined
const workspaceDraft = drafts.find(
(entry) =>
entry.buffer.target?.worktreeId === activeWorktreeId &&
entry.buffer.delivery?.state !== 'delivered'
)
const id = selected?.buffer.id ?? eligiblePending?.creationId ?? workspaceDraft?.buffer.id
const entry = id ? session.entries[id] : undefined
const repo = eligiblePending
? repos.find((item) => item.id === eligiblePending.request.repoId)
: undefined
const initial: CreationDraftInput | undefined =
entry?.buffer ??
(eligiblePending && repo
? {
id: eligiblePending.creationId,
title: eligiblePending.request.displayName || eligiblePending.request.name,
text: '',
updatedAt: eligiblePending.startedAt,
agent: eligiblePending.request.agent!,
executionHostId:
eligiblePending.request.workspaceRunContext?.hostId ?? getRepoExecutionHostId(repo)
}
: undefined)
const showEditor = initial && id !== hiddenId
if (!showEditor && drafts.length === 0 && !session.loadError) {
return null
}
return (
<div className="shrink-0 border-t border-border bg-editor-surface p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{showEditor ? (
<span className="min-w-0 flex-1 truncate">{initial.title}</span>
) : (
<span className="flex-1" />
)}
{drafts.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs">
{translate('creationDraft.savedDrafts', 'Saved drafts')} ({drafts.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
if (recoveringDraft.current) {
recoveringDraft.current = false
event.preventDefault()
editorRef.current?.focus()
}
}}
>
{drafts.map(({ buffer }) => (
<DropdownMenuItem
key={buffer.id}
onSelect={() => {
recoveringDraft.current = true
useCreationDraftSession.setState({ viewedDraftId: buffer.id })
setHiddenId(null)
}}
>
{buffer.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{showEditor ? (
<Button
variant="ghost"
size="xs"
onClick={() => {
setHiddenId(initial.id)
useCreationDraftSession.setState({ viewedDraftId: null })
}}
>
{translate('creationDraft.hide', 'Hide')}
</Button>
) : null}
</div>
{session.loadError ? (
<div role="alert" className="text-xs text-destructive">
{translate('creationDraft.loadFailed', 'Saved drafts could not be loaded.')}
<Button variant="link" size="xs" onClick={() => void loadCreationDrafts()}>
{translate('creationDraft.retry', 'Retry')}
</Button>
</div>
) : null}
{showEditor ? (
<CreationDraftEditor key={initial.id} initial={initial} editorRef={editorRef} />
) : null}
</div>
)
}
@@ -133,10 +133,11 @@ describe('durable composer creation launch boundary', () => {
expect(createWorktree.mock.calls[0][16]).toBeUndefined()
})
it('preserves ordinary backend agent launch', async () => {
it('launches the agent with renderer-owned completion focus', async () => {
const startup = { command: 'codex', launchAgent: 'codex' as const }
await createRequestedWorktree('submit', makeRequest({ agent: 'codex', startup }))
expect(createWorktree.mock.calls[0][16]).toEqual(startup)
expect(createWorktree.mock.calls[0][25]).toMatchObject({ callerOwnsCompletion: true })
})
it('preserves ordinary host-owned draft launch', async () => {
@@ -49,6 +49,7 @@ export async function createRequestedWorktree(
preparedRequest.linkedGiteaPR,
preparedRequest.compareBaseRef,
{
callerOwnsCompletion: !background,
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
...(preparedRequest.displayNameKind
? { displayNameKind: preparedRequest.displayNameKind }
@@ -0,0 +1,124 @@
const DATABASE_NAME = 'orca-workspace-creation-drafts'
export const DRAFT_STORE = 'drafts'
const OPEN_TIMEOUT_MS = 5000
let opening: Promise<IDBDatabase> | undefined
export function openCreationDraftDatabase(): Promise<IDBDatabase> {
if (opening) {
return opening
}
const pending = new Promise<IDBDatabase>((resolve, reject) => {
let finished = false
const request = indexedDB.open(DATABASE_NAME, 1)
const fail = (error: unknown): void => {
if (finished) {
return
}
finished = true
clearTimeout(timer)
reject(error)
}
const timer = setTimeout(
() => fail(new Error('Creation draft database open timed out')),
OPEN_TIMEOUT_MS
)
request.onupgradeneeded = () => {
if (finished) {
request.transaction?.abort()
return
}
request.result.createObjectStore(DRAFT_STORE, { keyPath: 'id' })
}
request.onerror = () => fail(request.error ?? new Error('Creation draft database open failed'))
request.onblocked = () =>
fail(
new Error(
'Creation draft database upgrade is blocked; retry after closing the other editor'
)
)
request.onsuccess = () => {
const database = request.result
if (finished) {
database.close()
return
}
finished = true
clearTimeout(timer)
const forget = (): void => {
if (opening === pending) {
opening = undefined
}
}
database.onversionchange = () => {
database.close()
forget()
}
database.onclose = forget
resolve(database)
}
})
opening = pending
void pending.catch(() => {
if (opening === pending) {
opening = undefined
}
})
return pending
}
export async function transactCreationDrafts<T>(
mode: IDBTransactionMode,
work: (store: IDBObjectStore, result: (value: T) => void, fail: (error: unknown) => void) => void
): Promise<T> {
const database = await openCreationDraftDatabase()
return new Promise<T>((resolve, reject) => {
const transaction = database.transaction(DRAFT_STORE, mode, { durability: 'strict' })
let value: T
let hasValue = false
let failure: unknown
const fail = (error: unknown): void => {
failure ??= error
try {
transaction.abort()
} catch {
/* A completed transaction cannot be aborted. */
}
}
const timer = setTimeout(() => {
const error = new Error('Creation draft transaction timed out')
fail(error)
reject(error)
}, 10000)
transaction.oncomplete = () => {
clearTimeout(timer)
if (failure || !hasValue) {
reject(failure ?? new Error('Creation draft transaction produced no result'))
} else {
resolve(value)
}
}
transaction.onabort = () => {
clearTimeout(timer)
reject(failure ?? transaction.error ?? new Error('Creation draft transaction aborted'))
}
transaction.onerror = () => {
failure ??= transaction.error
}
if (mode === 'readwrite' && transaction.durability !== 'strict') {
fail(new Error('Strict creation draft durability is unavailable'))
return
}
try {
work(
transaction.objectStore(DRAFT_STORE),
(next) => {
value = next
hasValue = true
},
fail
)
} catch (error) {
fail(error)
}
})
}
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const database = vi.hoisted(() => ({
listDrafts: vi.fn(),
saveDraft: vi.fn(),
deleteDraft: vi.fn()
}))
vi.mock('./creation-draft-database', () => database)
import {
CreationDraftCapacityError,
CreationDraftConflictError,
type CreationDraft,
type CreationDraftInput
} from './creation-draft-record'
import {
discardCreationDraft,
editCreationDraft,
flushCreationDraft,
saveCreationDraftCopy,
useCreationDraftSession
} from './creation-draft-session'
const source: CreationDraftInput = {
id: 'original',
title: 'Workspace',
text: 'Local text',
agent: 'codex',
executionHostId: 'local',
updatedAt: 1,
target: {
worktreeId: 'workspace',
terminalHandle: 'terminal',
incarnationId: 'incarnation',
tabId: 'tab'
},
delivery: { attemptId: 'attempt', revision: 1, state: 'uncertain' }
}
let records: Map<string, CreationDraft>
const entry = (id = source.id) => useCreationDraftSession.getState().entries[id]
const deferred = () => {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
async function save(input: CreationDraftInput, expected: number | null): Promise<CreationDraft> {
const current = records.get(input.id)
if ((current?.revision ?? null) !== expected) {
throw new CreationDraftConflictError(current ?? null)
}
const next = { ...input, revision: (expected ?? 0) + 1 }
records.set(input.id, next)
return next
}
async function conflict(): Promise<CreationDraft> {
const other = { ...source, text: 'Other window text', revision: 2 }
records.set(source.id, other)
editCreationDraft({ ...source, text: 'My edited text' })
await flushCreationDraft(source.id)
expect(entry().conflict).toBe(true)
return other
}
beforeEach(async () => {
vi.resetAllMocks()
records = new Map()
useCreationDraftSession.setState({
entries: {},
loaded: true,
loadError: null,
viewedDraftId: null
})
database.saveDraft.mockImplementation(save)
database.deleteDraft.mockImplementation(async (id: string, expected: number) => {
const current = records.get(id)
if (current?.revision !== expected) {
throw new CreationDraftConflictError(current ?? null)
}
records.delete(id)
})
editCreationDraft(source)
await flushCreationDraft(source.id)
})
describe('creation draft conflict copies', () => {
it('preserves the other window record and copies text without a send target or attempt', async () => {
const other = await conflict()
const id = await saveCreationDraftCopy(source.id)
expect(id).not.toBe(source.id)
expect(records.get(source.id)).toEqual(other)
expect(records.get(id)).toMatchObject({
text: 'My edited text',
target: { worktreeId: 'workspace' }
})
expect(records.get(id)?.target).toEqual({ worktreeId: 'workspace' })
expect(records.get(id)?.delivery).toBeUndefined()
expect(entry()).toBeUndefined()
expect(entry(id).conflict).toBeFalsy()
expect(entry(id).savedVersion).toBe(entry(id).editVersion)
expect(useCreationDraftSession.getState().viewedDraftId).toBe(id)
expect(database.deleteDraft).not.toHaveBeenCalled()
})
it('commits concurrent edits to the new ID before removing the stale local source', async () => {
const other = await conflict()
const gate = deferred()
database.saveDraft.mockImplementationOnce(async (input, revision) => {
await gate.promise
return save(input, revision)
})
const copy = saveCreationDraftCopy(source.id)
await vi.waitFor(() => expect(database.saveDraft).toHaveBeenCalledTimes(3))
editCreationDraft({ ...entry().buffer, text: 'Newest text' })
expect(entry().buffer.text).toBe('Newest text')
gate.resolve()
const id = await copy
expect(records.get(id)?.text).toBe('Newest text')
expect(entry(id).buffer.text).toBe('Newest text')
expect(records.get(source.id)).toEqual(other)
expect(database.saveDraft.mock.calls.slice(2).every(([input]) => input.id === id)).toBe(true)
})
it('awaits a pending source save and deduplicates simultaneous copy requests', async () => {
const gate = deferred()
database.saveDraft.mockImplementationOnce(async (input, revision) => {
await gate.promise
return save(input, revision)
})
editCreationDraft({ ...source, text: 'Pending save' })
await Promise.resolve()
const first = saveCreationDraftCopy(source.id)
expect(saveCreationDraftCopy(source.id)).toBe(first)
await Promise.resolve()
expect(database.saveDraft).toHaveBeenCalledTimes(2)
gate.resolve()
const id = await first
expect(records.get(source.id)?.text).toBe('Pending save')
expect(records.get(id)?.text).toBe('Pending save')
expect(records.size).toBe(2)
})
it('keeps the local buffer and original database record when capacity prevents a copy', async () => {
const other = await conflict()
database.saveDraft.mockRejectedValueOnce(new CreationDraftCapacityError())
await expect(saveCreationDraftCopy(source.id)).rejects.toThrow('full')
expect(entry().buffer.text).toBe('My edited text')
expect(entry().conflict).toBe(true)
expect(records.get(source.id)).toEqual(other)
expect(records.size).toBe(1)
expect(database.deleteDraft).not.toHaveBeenCalled()
})
it('keeps newest source text and removes an incomplete copy if catch-up saving fails', async () => {
const other = await conflict()
database.saveDraft
.mockImplementationOnce(async (input, revision) => {
editCreationDraft({ ...entry().buffer, text: 'Newest unsaved text' })
return save(input, revision)
})
.mockRejectedValueOnce(new Error('disk unavailable'))
await expect(saveCreationDraftCopy(source.id)).rejects.toThrow('disk unavailable')
expect(entry().buffer.text).toBe('Newest unsaved text')
expect(records.get(source.id)).toEqual(other)
expect(records.size).toBe(1)
})
it('bounds catch-up attempts while preserving source edits', async () => {
await conflict()
let edits = 0
database.saveDraft.mockImplementation(async (input, revision) => {
editCreationDraft({ ...entry().buffer, text: `Concurrent edit ${++edits}` })
return save(input, revision)
})
await expect(saveCreationDraftCopy(source.id)).rejects.toThrow('kept changing')
expect(edits).toBe(3)
expect(entry().buffer.text).toBe('Concurrent edit 3')
expect(records.size).toBe(1)
})
it('exposes conflicts discovered by discard without deleting either text', async () => {
const other = { ...source, text: 'Other window text', revision: 2 }
records.set(source.id, other)
await expect(discardCreationDraft(source.id)).rejects.toThrow('another editor')
expect(entry().conflict).toBe(true)
expect(entry().buffer.text).toBe(source.text)
expect(records.get(source.id)).toEqual(other)
})
})
@@ -0,0 +1,118 @@
import {
CREATION_DRAFT_LIMIT,
CreationDraftCapacityError,
CreationDraftConflictError,
creationDraftInputSchema,
creationDraftSchema,
type CreationDraft,
type CreationDraftInput
} from './creation-draft-record'
import { transactCreationDrafts } from './creation-draft-connection'
export { CreationDraftConflictError, CreationDraftCapacityError } from './creation-draft-record'
function readDraft(value: unknown, id?: string): CreationDraft | null {
if (value === undefined) {
return null
}
const draft = creationDraftSchema.parse(value)
if (id !== undefined && draft.id !== id) {
throw new Error('Creation draft storage identity mismatch')
}
return draft
}
export function getDraft(id: string): Promise<CreationDraft | null> {
return transactCreationDrafts('readonly', (store, result, fail) => {
const request = store.get(id)
request.onsuccess = () => {
try {
result(readDraft(request.result, id))
} catch (error) {
fail(error)
}
}
})
}
export function listDrafts(): Promise<CreationDraft[]> {
return transactCreationDrafts('readonly', (store, result, fail) => {
const request = store.getAll(undefined, CREATION_DRAFT_LIMIT + 1)
request.onsuccess = () => {
try {
if (request.result.length > CREATION_DRAFT_LIMIT) {
throw new CreationDraftCapacityError()
}
result(request.result.map((value: unknown) => creationDraftSchema.parse(value)))
} catch (error) {
fail(error)
}
}
})
}
function assertRevision(current: CreationDraft | null, expected: number | null): void {
if (expected !== null && (!Number.isSafeInteger(expected) || expected < 1)) {
throw new Error('Invalid expected creation draft revision')
}
if ((current?.revision ?? null) !== expected) {
throw new CreationDraftConflictError(current)
}
}
export async function saveDraft(
input: CreationDraftInput,
expectedRevision: number | null
): Promise<CreationDraft> {
const parsed = creationDraftInputSchema.parse(input)
return transactCreationDrafts('readwrite', (store, result, fail) => {
const request = store.get(parsed.id)
request.onsuccess = () => {
try {
const current = readDraft(request.result, parsed.id)
assertRevision(current, expectedRevision)
const next = creationDraftSchema.parse({
...parsed,
revision: (current?.revision ?? 0) + 1
})
const write = (): void => {
store.put(next)
result(next)
}
if (current) {
write()
return
}
const count = store.count()
count.onsuccess = () => {
try {
if (count.result >= CREATION_DRAFT_LIMIT) {
throw new CreationDraftCapacityError()
}
write()
} catch (error) {
fail(error)
}
}
} catch (error) {
fail(error)
}
}
})
}
export function deleteDraft(id: string, expectedRevision: number): Promise<void> {
return transactCreationDrafts('readwrite', (store, result, fail) => {
const request = store.get(id)
request.onsuccess = () => {
try {
const current = readDraft(request.result, id)
assertRevision(current, expectedRevision)
store.delete(id)
result(undefined)
} catch (error) {
fail(error)
}
}
})
}
@@ -0,0 +1,223 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
RUNTIME_CAPABILITIES,
TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
const mocks = vi.hoisted(() => ({
rpc: vi.fn(),
ready: vi.fn(),
capabilities: vi.fn(),
web: false,
state: {
repos: [{ id: 'repo', executionHostId: 'local', connectionId: null }],
worktreesByRepo: { repo: [{ id: 'worktree', repoId: 'repo', hostId: 'local' }] }
}
}))
vi.mock('./creation-draft-readiness', () => ({ isCreationDraftInputReady: mocks.ready }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.rpc }))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
refreshLocalRuntimeCapabilities: mocks.capabilities
}))
vi.mock('../web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
vi.mock('../new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' }))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
sendRuntimePtyInputVerified: vi.fn(() => {
throw new Error('PTY fallback forbidden')
})
}))
import { captureCreationDraftTarget, sendCreationDraft } from './creation-draft-delivery'
const target = {
executionHostId: 'local',
worktreeId: 'worktree',
terminalHandle: 'term-1',
incarnationId: 'inc-1'
}
const terminal = {
handle: 'term-1',
ptyId: 'pty-1',
incarnationId: 'inc-1',
worktreeId: 'worktree',
executionHostId: 'local',
connected: true,
writable: true
}
const listing = () => ({ terminals: [{ ...terminal }], totalCount: 1, truncated: false })
const sends = () => mocks.rpc.mock.calls.filter((call) => call[1] === 'terminal.send')
const accepted = (params: { text?: string }) => ({
send: {
handle: 'term-1',
accepted: true,
bytesWritten: params.text === undefined ? 1 : new TextEncoder().encode(params.text).byteLength
}
})
async function deliver(text = 'hello') {
const pending = sendCreationDraft({ target, text })
await vi.runAllTimersAsync()
return pending
}
describe('explicit creation draft delivery', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', { setTimeout })
mocks.web = false
mocks.state.worktreesByRepo = { repo: [{ id: 'worktree', repoId: 'repo', hostId: 'local' }] }
mocks.rpc.mockReset()
mocks.ready.mockReset().mockResolvedValue(true)
mocks.capabilities.mockResolvedValue([TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY])
mocks.rpc.mockImplementation(async (_target, method, params) =>
method === 'terminal.list' ? listing() : accepted(params)
)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('refuses startup without writing any bytes', async () => {
mocks.ready.mockResolvedValue(false)
expect(await deliver()).toEqual({ status: 'refused', reason: 'input-not-ready' })
expect(sends()).toEqual([])
})
it('preserves uncertainty without Enter or cleanup writes if readiness is lost after paste', async () => {
mocks.ready.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
expect(await deliver()).toEqual({ status: 'uncertain', reason: 'partial-delivery' })
expect(sends()).toHaveLength(3)
expect(sends().some((call) => call[2].enter)).toBe(false)
})
it('advertises the implemented terminal.send fence', () => {
expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY)
})
it('refuses old hosts before any send or target lookup', async () => {
mocks.capabilities.mockResolvedValue([])
expect(await deliver()).toEqual({ status: 'refused', reason: 'unsupported-runtime' })
expect(mocks.rpc).not.toHaveBeenCalled()
expect(await captureCreationDraftTarget(target)).toBeNull()
})
it.each(['ssh:host', 'runtime:host'])(
'refuses %s without contacting local runtime',
async (executionHostId) => {
expect(
await sendCreationDraft({ target: { ...target, executionHostId }, text: 'hello' })
).toEqual({ status: 'refused', reason: 'unsupported-host' })
expect(mocks.rpc).not.toHaveBeenCalled()
}
)
it('refuses paired browser contexts and ownership changes', async () => {
mocks.web = true
expect(await deliver()).toMatchObject({ status: 'refused' })
mocks.web = false
mocks.state.worktreesByRepo = {
repo: [{ id: 'worktree', repoId: 'repo', hostId: 'runtime:other' }]
}
expect(await deliver()).toMatchObject({ status: 'refused' })
expect(sends()).toEqual([])
})
it.each(['incarnationId', 'worktreeId', 'handle', 'executionHostId'])(
'refuses mismatched %s before bytes',
async (field) => {
mocks.rpc.mockResolvedValue({ ...listing(), terminals: [{ ...terminal, [field]: 'other' }] })
expect(await deliver()).toEqual({ status: 'refused', reason: 'unverified-target' })
expect(sends()).toEqual([])
}
)
it('captures only the provided terminal handle without fallback', async () => {
expect(await captureCreationDraftTarget(target)).toEqual({
terminalHandle: 'term-1',
incarnationId: 'inc-1'
})
expect(mocks.rpc).toHaveBeenCalledWith({ kind: 'local' }, 'terminal.list', {
handles: ['term-1'],
includeVisualLayouts: false
})
mocks.rpc.mockResolvedValue({
...listing(),
terminals: [{ ...terminal, handle: 'replacement' }]
})
expect(await captureCreationDraftTarget(target)).toBeNull()
})
it('sanitizes framing, normalizes line endings and submits exactly once through fenced RPC', async () => {
expect(await deliver('hello\n\x1b[201~danger')).toEqual({ status: 'delivered' })
expect(sends().map((call) => call[2].text ?? 'ENTER')).toEqual([
'\x1b[200~',
'hello\r␛[201~danger',
'\x1b[201~',
'ENTER'
])
for (const call of sends()) {
expect(call[0]).toEqual({ kind: 'local' })
expect(call[2]).toMatchObject({
terminal: 'term-1',
expectedIncarnationId: 'inc-1',
requireAgentStatus: 'sendable'
})
}
})
it('returns refusal when the first frame is refused without bytes', async () => {
mocks.rpc.mockImplementation(async (_target, method) =>
method === 'terminal.list' ? listing() : { send: { accepted: false, bytesWritten: 0 } }
)
expect(await deliver()).toEqual({ status: 'refused', reason: 'not-sendable' })
expect(sends()).toHaveLength(1)
})
it('reports uncertainty after text acceptance and Enter refusal without retry', async () => {
mocks.rpc.mockImplementation(async (_target, method, params) =>
method === 'terminal.list'
? listing()
: params.enter
? { send: { accepted: false, bytesWritten: 0 } }
: accepted(params)
)
expect(await deliver()).toEqual({ status: 'uncertain', reason: 'partial-delivery' })
expect(sends()).toHaveLength(4)
})
it('never retries after an ambiguous first send', async () => {
mocks.rpc.mockImplementation(async (_target, method) => {
if (method === 'terminal.list') {
return listing()
}
throw new Error('connection lost after host write')
})
expect(await deliver()).toEqual({ status: 'uncertain', reason: 'transport' })
expect(sends()).toHaveLength(1)
})
it('bounds every sanitized text RPC to one runtime input chunk', async () => {
expect(await deliver('é'.repeat(32 * 1024))).toEqual({ status: 'delivered' })
for (const call of sends()) {
if (typeof call[2].text === 'string') {
expect(new TextEncoder().encode(call[2].text).byteLength).toBeLessThanOrEqual(16 * 1024)
}
}
expect(sends().filter((call) => call[2].enter)).toHaveLength(1)
})
it('stops after an ownership change between accepted frames', async () => {
mocks.rpc.mockImplementation(async (_target, method, params) => {
if (method === 'terminal.list') {
return listing()
}
mocks.state.worktreesByRepo = {
repo: [{ id: 'worktree', repoId: 'repo', hostId: 'runtime:other' }]
}
return accepted(params)
})
expect(await deliver()).toEqual({ status: 'uncertain', reason: 'partial-delivery' })
expect(sends()).toHaveLength(1)
})
it('does not claim delivery after an incomplete acknowledgement', async () => {
mocks.rpc.mockImplementation(async (_target, method) =>
method === 'terminal.list'
? listing()
: { send: { handle: 'term-1', accepted: true, bytesWritten: 0 } }
)
expect(await deliver()).toEqual({ status: 'uncertain', reason: 'partial-delivery' })
expect(sends()).toHaveLength(1)
})
})
@@ -0,0 +1,201 @@
import { useAppStore } from '@/store'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { refreshLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction'
import { iterateAgentDraftPasteContentChunks } from '../agent-draft-paste-content'
import { resolveExplicitWorktreeOperationRouteResult } from '../worktree-operation-route'
import { isWebClientLocation } from '../web-client-location'
import { CLIENT_PLATFORM } from '../new-workspace'
import { getAgentPromptSubmitDelayMs } from '../../../../shared/agent-prompt-injection'
import { TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type {
RuntimeTerminalListResult,
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import { isCreationDraftInputReady } from './creation-draft-readiness'
import { CREATION_DRAFT_TEXT_BYTES } from './creation-draft-record'
export type CreationDraftDeliveryTarget = {
executionHostId: string
worktreeId: string
terminalHandle: string
incarnationId: string
}
export type CreationDraftDeliveryResult =
| { status: 'delivered' }
| {
status: 'refused'
reason:
| 'unsupported-host'
| 'unsupported-runtime'
| 'unverified-target'
| 'invalid-text'
| 'not-sendable'
| 'input-not-ready'
}
| { status: 'uncertain'; reason: 'transport' | 'partial-delivery' }
type UnverifiedDraftTarget = Omit<CreationDraftDeliveryTarget, 'incarnationId'>
function hasLocalOwner(target: UnverifiedDraftTarget): boolean {
const resolution = resolveExplicitWorktreeOperationRouteResult(
useAppStore.getState(),
target.worktreeId
)
return (
resolution.kind === 'resolved' &&
resolution.route.executionHostId === 'local' &&
resolution.route.runtimeEnvironmentId === null
)
}
async function readNativeTarget(target: UnverifiedDraftTarget) {
if (
isWebClientLocation() ||
target.executionHostId !== 'local' ||
!target.worktreeId ||
!target.terminalHandle ||
!hasLocalOwner(target)
) {
return null
}
try {
const listing = await callRuntimeRpc<RuntimeTerminalListResult>(
{ kind: 'local' },
'terminal.list',
{
handles: [target.terminalHandle],
includeVisualLayouts: false
}
)
const matches = listing.terminals.filter((entry) => entry.handle === target.terminalHandle)
const terminal = matches.length === 1 ? matches[0] : undefined
return terminal?.ptyId &&
terminal.incarnationId &&
terminal.worktreeId === target.worktreeId &&
terminal.executionHostId === 'local' &&
terminal.connected &&
terminal.writable &&
hasLocalOwner(target)
? terminal
: null
} catch {
return null
}
}
export async function captureCreationDraftTarget(args: UnverifiedDraftTarget): Promise<{
terminalHandle: string
incarnationId: string
} | null> {
const target = { ...args }
if (isWebClientLocation() || target.executionHostId !== 'local' || !hasLocalOwner(target)) {
return null
}
if (
!(await refreshLocalRuntimeCapabilities()).includes(
TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY
)
) {
return null
}
const terminal = await readNativeTarget(target)
return terminal?.incarnationId
? {
terminalHandle: target.terminalHandle,
incarnationId: terminal.incarnationId
}
: null
}
/** The caller must persist its sending attempt first; this operation never retries. */
export async function sendCreationDraft(args: {
target: CreationDraftDeliveryTarget
text: string
}): Promise<CreationDraftDeliveryResult> {
const target = { ...args.target }
const text = args.text
if (isWebClientLocation() || target.executionHostId !== 'local') {
return { status: 'refused', reason: 'unsupported-host' }
}
if (
!target.worktreeId ||
!target.terminalHandle ||
!target.incarnationId ||
!hasLocalOwner(target)
) {
return { status: 'refused', reason: 'unverified-target' }
}
const byteLength = new TextEncoder().encode(text).byteLength
if (!text.trim() || byteLength > CREATION_DRAFT_TEXT_BYTES) {
return { status: 'refused', reason: 'invalid-text' }
}
if (
!(await refreshLocalRuntimeCapabilities()).includes(
TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY
)
) {
return { status: 'refused', reason: 'unsupported-runtime' }
}
const terminal = await readNativeTarget(target)
if (!terminal?.ptyId || terminal.incarnationId !== target.incarnationId) {
return { status: 'refused', reason: 'unverified-target' }
}
return runTerminalPtyInputTransaction(terminal.ptyId, async () => {
let acceptedBytes = false
if (!(await isCreationDraftInputReady(target.terminalHandle))) {
return { status: 'refused', reason: 'input-not-ready' }
}
const send = async (
payload: { text: string } | { enter: true }
): Promise<CreationDraftDeliveryResult | null> => {
if (!hasLocalOwner(target)) {
return acceptedBytes
? { status: 'uncertain', reason: 'partial-delivery' }
: { status: 'refused', reason: 'unverified-target' }
}
try {
const { send: result } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
{ kind: 'local' },
'terminal.send',
{
terminal: target.terminalHandle,
expectedIncarnationId: target.incarnationId,
requireAgentStatus: 'sendable',
client: { id: 'orca-desktop', type: 'desktop' },
...payload
}
)
if (result.accepted !== true) {
return acceptedBytes || result.bytesWritten !== 0
? { status: 'uncertain', reason: 'partial-delivery' }
: { status: 'refused', reason: 'not-sendable' }
}
const expectedBytes =
'text' in payload ? new TextEncoder().encode(payload.text).byteLength : 1
if (result.handle !== target.terminalHandle || result.bytesWritten !== expectedBytes) {
return { status: 'uncertain', reason: 'partial-delivery' }
}
acceptedBytes = true
return null
} catch {
return { status: 'uncertain', reason: 'transport' }
}
}
// Each bounded frame uses one guarded RPC; a later refusal never replays earlier frames.
for (const chunk of iterateAgentDraftPasteContentChunks(text)) {
const failure = await send({ text: chunk })
if (failure) {
return failure
}
}
await new Promise<void>((resolve) =>
window.setTimeout(resolve, getAgentPromptSubmitDelayMs(CLIENT_PLATFORM, byteLength))
)
if (!(await isCreationDraftInputReady(target.terminalHandle))) {
return { status: 'uncertain', reason: 'partial-delivery' }
}
return (await send({ enter: true })) ?? { status: 'delivered' }
})
}
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isCreationDraftInputReady } from './creation-draft-readiness'
const rpc = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc }))
const ready = {
handle: 'term-1',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
}
describe('creation draft input readiness', () => {
beforeEach(() => {
vi.useFakeTimers()
rpc.mockReset().mockResolvedValue({ wait: { ...ready } })
})
afterEach(() => vi.useRealTimers())
it('uses retained host readiness and releases its deadline after positive evidence', async () => {
expect(await isCreationDraftInputReady('term-1')).toBe(true)
expect(rpc).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }, 'terminal.wait', {
terminal: 'term-1',
for: 'tui-idle',
timeoutMs: 100
})
expect(vi.getTimerCount()).toBe(0)
})
it.each([
'codex-update-prompt',
'codex-trust-workspace',
'codex-model-migration-prompt',
'codex-hooks-review-prompt',
'agent-approval-prompt'
])('refuses %s even if an old idle signal is also present', async (blockedReason) => {
rpc.mockResolvedValue({ wait: { ...ready, blockedReason } })
expect(await isCreationDraftInputReady('term-1')).toBe(false)
})
it.each([
{ satisfied: false },
{ status: 'exited' },
{ status: 'unknown' },
{ handle: 'replacement' },
{ condition: 'exit' }
])('refuses contradictory evidence %j', async (change) => {
rpc.mockResolvedValue({ wait: { ...ready, ...change } })
expect(await isCreationDraftInputReady('term-1')).toBe(false)
})
it('does not treat a host timeout as readiness', async () => {
rpc.mockRejectedValue(new Error('timeout'))
expect(await isCreationDraftInputReady('term-1')).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('bounds an unresponsive transport and never turns late evidence into delivery', async () => {
let respond!: (value: unknown) => void
rpc.mockImplementation(
() =>
new Promise((resolve) => {
respond = resolve
})
)
const pending = isCreationDraftInputReady('term-1')
await vi.advanceTimersByTimeAsync(1000)
expect(await pending).toBe(false)
respond({ wait: ready })
await Promise.resolve()
expect(rpc).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,30 @@
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { RuntimeTerminalWait } from '../../../../shared/runtime-types'
/** Uses host-retained state so mounting xterm cannot consume our readiness evidence. */
export async function isCreationDraftInputReady(terminalHandle: string): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
callRuntimeRpc<{ wait: RuntimeTerminalWait }>({ kind: 'local' }, 'terminal.wait', {
terminal: terminalHandle,
for: 'tui-idle',
timeoutMs: 100
}).then(
({ wait }) =>
wait?.handle === terminalHandle &&
wait.condition === 'tui-idle' &&
wait.status === 'running' &&
wait.satisfied === true &&
wait.blockedReason === undefined
),
new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), 1000)
})
])
} catch {
return false
} finally {
clearTimeout(timer)
}
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { creationDraftInputSchema, creationDraftSchema } from './creation-draft-record'
const draft = {
id: 'draft',
title: 'Workspace',
text: '',
updatedAt: 1,
agent: 'codex',
executionHostId: 'local'
}
describe('creation draft validation', () => {
it('bounds UTF-8 bytes rather than UTF-16 character count', () => {
expect(
creationDraftInputSchema.parse({ ...draft, text: '😀'.repeat(16384) }).text
).toHaveLength(32768)
expect(() => creationDraftInputSchema.parse({ ...draft, text: '😀'.repeat(16385) })).toThrow()
})
it('accepts workspace ownership before a terminal binding exists', () => {
expect(
creationDraftSchema.parse({ ...draft, revision: 1, target: { worktreeId: 'workspace' } })
.target
).toEqual({ worktreeId: 'workspace' })
})
it('preserves workspace IDs containing long host paths', () => {
const worktreeId = `repo::${'nested/'.repeat(100)}workspace`
expect(creationDraftInputSchema.parse({ ...draft, target: { worktreeId } }).target).toEqual({
worktreeId
})
})
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
'rejects invalid revision %s',
(revision) => {
expect(() => creationDraftSchema.parse({ ...draft, revision })).toThrow()
}
)
it('refuses malformed stored delivery and unknown fields', () => {
expect(() =>
creationDraftSchema.parse({
...draft,
revision: 1,
delivery: { attemptId: 'attempt', revision: 1, state: 'retry' }
})
).toThrow()
expect(() =>
creationDraftSchema.parse({ ...draft, revision: 1, secret: 'unexpected' })
).toThrow()
})
})
@@ -0,0 +1,52 @@
import { z } from 'zod'
export const CREATION_DRAFT_LIMIT = 64
export const CREATION_DRAFT_TEXT_BYTES = 64 * 1024
const identity = z.string().min(1).max(512)
const revision = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const draftFields = {
id: identity,
title: z.string().max(4096),
text: z
.string()
.refine(
(text) => new TextEncoder().encode(text).byteLength <= CREATION_DRAFT_TEXT_BYTES,
'Creation draft exceeds 64 KiB'
),
updatedAt: z.number().finite().nonnegative(),
agent: identity,
executionHostId: identity,
target: z
.object({
worktreeId: z.string().min(1).max(65536),
terminalHandle: identity.optional(),
incarnationId: identity.optional(),
tabId: identity.optional()
})
.strict()
.optional(),
delivery: z
.object({ attemptId: identity, revision, state: z.enum(['sending', 'uncertain', 'delivered']) })
.strict()
.optional()
}
export const creationDraftSchema = z.object({ ...draftFields, revision }).strict()
export const creationDraftInputSchema = z.object(draftFields).strict()
export type CreationDraft = z.infer<typeof creationDraftSchema>
export type CreationDraftInput = Omit<CreationDraft, 'revision'>
export class CreationDraftConflictError extends Error {
constructor(readonly current: CreationDraft | null) {
super('Creation draft changed in another editor')
this.name = 'CreationDraftConflictError'
}
}
export class CreationDraftCapacityError extends Error {
constructor() {
super('Creation draft storage is full (64 drafts)')
this.name = 'CreationDraftCapacityError'
}
}
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const database = vi.hoisted(() => ({
listDrafts: vi.fn(),
saveDraft: vi.fn(),
deleteDraft: vi.fn()
}))
vi.mock('./creation-draft-database', () => database)
import {
bindCreationDraft,
discardCreationDraft,
editCreationDraft,
flushCreationDraft,
loadCreationDrafts,
useCreationDraftSession
} from './creation-draft-session'
import type { CreationDraft, CreationDraftInput } from './creation-draft-record'
const draft = (text: string, id = 'create-1'): CreationDraftInput => ({
id,
text,
title: 'Workspace',
agent: 'codex',
executionHostId: 'local',
updatedAt: 1
})
const deferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
beforeEach(() => {
vi.resetAllMocks()
useCreationDraftSession.setState({
entries: {},
loaded: false,
loadError: null,
viewedDraftId: null
})
database.listDrafts.mockResolvedValue([])
database.saveDraft.mockImplementation(
async (input: CreationDraftInput, revision: number | null) => ({
...input,
revision: (revision ?? 0) + 1
})
)
})
describe('creation draft session ownership', () => {
it('keeps edits and workspace binding made while an earlier save is pending', async () => {
const first = deferred<CreationDraft>()
database.saveDraft.mockImplementationOnce(() => first.promise)
editCreationDraft(draft('first'))
await Promise.resolve()
editCreationDraft(draft('second'))
bindCreationDraft('create-1', { worktreeId: 'workspace-1', terminalHandle: 'original' })
first.resolve({ ...draft('first'), revision: 1 })
await flushCreationDraft('create-1')
const entry = useCreationDraftSession.getState().entries['create-1']
expect(entry.buffer).toMatchObject({
text: 'second',
target: { worktreeId: 'workspace-1', terminalHandle: 'original' }
})
expect(entry.savedVersion).toBe(entry.editVersion)
expect(database.saveDraft).toHaveBeenLastCalledWith(entry.buffer, 1)
})
it('does not let late hydration replace text typed in the current window', async () => {
const loading = deferred<CreationDraft[]>()
database.listDrafts.mockReturnValue(loading.promise)
const load = loadCreationDrafts()
editCreationDraft(draft('current'))
await flushCreationDraft('create-1')
loading.resolve([
{ ...draft('older'), revision: 1 },
{ ...draft('other window', 'create-2'), revision: 1 }
])
await load
expect(useCreationDraftSession.getState().entries['create-1'].buffer.text).toBe('current')
expect(useCreationDraftSession.getState().entries['create-2'].buffer.text).toBe('other window')
})
it('keeps unsaved text after failure and only retries when requested', async () => {
database.saveDraft.mockRejectedValueOnce(new Error('disk unavailable'))
editCreationDraft(draft('keep me'))
await flushCreationDraft('create-1')
expect(database.saveDraft).toHaveBeenCalledOnce()
expect(useCreationDraftSession.getState().entries['create-1']).toMatchObject({
buffer: { text: 'keep me' },
error: 'disk unavailable',
savedVersion: 0
})
await flushCreationDraft('create-1')
expect(useCreationDraftSession.getState().entries['create-1']).toMatchObject({
error: null,
storedRevision: 1
})
})
it('does not discard newer text typed during a pending deletion', async () => {
editCreationDraft(draft('old'))
await flushCreationDraft('create-1')
const deletion = deferred<void>()
database.deleteDraft.mockReturnValue(deletion.promise)
const removing = discardCreationDraft('create-1')
await Promise.resolve()
editCreationDraft(draft('newer'))
await flushCreationDraft('create-1')
deletion.resolve()
await removing
const entry = useCreationDraftSession.getState().entries['create-1']
expect(entry.buffer.text).toBe('newer')
expect(entry.savedVersion).toBe(entry.editVersion)
expect(database.saveDraft).toHaveBeenLastCalledWith(entry.buffer, null)
})
})
@@ -0,0 +1,283 @@
import { create } from 'zustand'
import { createBrowserUuid } from '../browser-uuid'
import { deleteDraft, listDrafts, saveDraft } from './creation-draft-database'
import {
CreationDraftConflictError,
type CreationDraft,
type CreationDraftInput
} from './creation-draft-record'
export type CreationDraftEntry = {
buffer: CreationDraftInput
storedRevision: number | null
editVersion: number
savedVersion: number
error: string | null
conflict?: boolean
}
type CreationDraftSession = {
entries: Record<string, CreationDraftEntry>
loaded: boolean
loadError: string | null
viewedDraftId: string | null
}
export const useCreationDraftSession = create<CreationDraftSession>(() => ({
entries: {},
loaded: false,
loadError: null,
viewedDraftId: null
}))
const writes = new Map<string, Promise<void>>()
const copies = new Map<string, Promise<string>>()
let loading: Promise<void> | undefined
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function fromStored(draft: CreationDraft): CreationDraftEntry {
const { revision, ...buffer } = draft
return { buffer, storedRevision: revision, editVersion: 0, savedVersion: 0, error: null }
}
export function loadCreationDrafts(): Promise<void> {
if (loading) {
return loading
}
if (useCreationDraftSession.getState().loaded) {
return Promise.resolve()
}
loading = listDrafts()
.then((drafts) => {
useCreationDraftSession.setState((state) => {
const entries = { ...state.entries }
for (const draft of drafts) {
if (!entries[draft.id]) {
entries[draft.id] = fromStored(draft)
}
}
return { entries, loaded: true, loadError: null }
})
})
.catch((error: unknown) => {
useCreationDraftSession.setState({ loadError: message(error) })
})
.finally(() => {
loading = undefined
})
return loading
}
export function editCreationDraft(buffer: CreationDraftInput): void {
const previous = useCreationDraftSession.getState().entries[buffer.id]
useCreationDraftSession.setState((state) => ({
entries: {
...state.entries,
[buffer.id]: {
buffer,
storedRevision: previous?.storedRevision ?? null,
editVersion: (previous?.editVersion ?? 0) + 1,
savedVersion: previous?.savedVersion ?? 0,
error: null,
conflict: previous?.conflict
}
}
}))
void flushCreationDraft(buffer.id)
}
export function bindCreationDraft(
id: string,
target: NonNullable<CreationDraftInput['target']>
): boolean {
const entry = useCreationDraftSession.getState().entries[id]
if (!entry) {
return false
}
editCreationDraft({ ...entry.buffer, target, updatedAt: Date.now() })
return true
}
export function flushCreationDraft(id: string): Promise<void> {
const existing = writes.get(id)
if (existing) {
return existing
}
const copying = copies.get(id)
if (copying) {
return copying.then(
() => undefined,
() => undefined
)
}
const work = Promise.resolve()
.then(async () => {
while (true) {
const entry = useCreationDraftSession.getState().entries[id]
if (!entry || entry.editVersion === entry.savedVersion) {
return
}
try {
const saved = await saveDraft(entry.buffer, entry.storedRevision)
useCreationDraftSession.setState((state) => {
const current = state.entries[id]
if (!current) {
return state
}
return {
entries: {
...state.entries,
[id]: {
...current,
storedRevision: saved.revision,
savedVersion: entry.editVersion,
error: null,
conflict: false
}
}
}
})
} catch (error) {
useCreationDraftSession.setState((state) => {
const current = state.entries[id]
if (!current) {
return state
}
return {
entries: {
...state.entries,
[id]: {
...current,
error: message(error),
conflict: current.conflict || error instanceof CreationDraftConflictError
}
}
}
})
return
}
}
})
.finally(() => {
writes.delete(id)
const current = useCreationDraftSession.getState().entries[id]
if (current && !current.error && current.editVersion !== current.savedVersion) {
void flushCreationDraft(id)
}
})
writes.set(id, work)
return work
}
export async function discardCreationDraft(id: string): Promise<void> {
const copying = copies.get(id)
if (copying) {
await copying
}
await writes.get(id)
const entry = useCreationDraftSession.getState().entries[id]
if (!entry) {
return
}
if (entry.storedRevision !== null) {
try {
await deleteDraft(id, entry.storedRevision)
} catch (error) {
if (error instanceof CreationDraftConflictError) {
useCreationDraftSession.setState((state) => {
const current = state.entries[id]
return current
? {
entries: {
...state.entries,
[id]: { ...current, conflict: true, error: message(error) }
}
}
: state
})
}
throw error
}
}
useCreationDraftSession.setState((state) => {
const entries = { ...state.entries }
if (entries[id]?.editVersion !== entry.editVersion) {
entries[id] = { ...entries[id], storedRevision: null, savedVersion: 0 }
return { entries }
}
delete entries[id]
return { entries, viewedDraftId: state.viewedDraftId === id ? null : state.viewedDraftId }
})
// An edit during deletion remains a draft and needs a new durable record.
if (useCreationDraftSession.getState().entries[id]) {
await flushCreationDraft(id)
}
}
export function saveCreationDraftCopy(id: string): Promise<string> {
const existing = copies.get(id)
if (existing) {
return existing
}
const copying = Promise.resolve()
.then(async () => {
await writes.get(id)
const copyId = createBrowserUuid()
let saved: CreationDraft | undefined
try {
for (let attempt = 0; attempt < 3; attempt++) {
const source = useCreationDraftSession.getState().entries[id]
if (!source) {
throw new Error('The draft is no longer available')
}
const { target, delivery: _delivery, ...buffer } = source.buffer
saved = await saveDraft(
{
...buffer,
id: copyId,
updatedAt: Date.now(),
...(target ? { target: { worktreeId: target.worktreeId } } : {})
},
saved?.revision ?? null
)
if (useCreationDraftSession.getState().entries[id]?.editVersion !== source.editVersion) {
continue
}
const committed = fromStored(saved)
useCreationDraftSession.setState((state) => {
const entries = { ...state.entries, [copyId]: committed }
delete entries[id]
return { entries, viewedDraftId: copyId }
})
return copyId
}
throw new Error('The draft kept changing while saving a copy. Try again.')
} catch (error) {
if (saved) {
try {
await deleteDraft(copyId, saved.revision)
} catch {
const retained = fromStored(saved)
useCreationDraftSession.setState((state) => ({
entries: { ...state.entries, [copyId]: retained }
}))
}
}
useCreationDraftSession.setState((state) => {
const source = state.entries[id]
return source
? { entries: { ...state.entries, [id]: { ...source, error: message(error) } } }
: state
})
throw error
}
})
.finally(() => {
copies.delete(id)
})
copies.set(id, copying)
return copying
}
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const database = vi.hoisted(() => ({
listDrafts: vi.fn(),
saveDraft: vi.fn(),
deleteDraft: vi.fn()
}))
const delivery = vi.hoisted(() => ({
captureCreationDraftTarget: vi.fn(),
sendCreationDraft: vi.fn()
}))
vi.mock('./creation-draft-database', () => database)
vi.mock('./creation-draft-delivery', () => delivery)
import {
CreationDraftConflictError,
type CreationDraft,
type CreationDraftInput
} from './creation-draft-record'
import {
editCreationDraft,
flushCreationDraft,
useCreationDraftSession
} from './creation-draft-session'
import { submitCreationDraft } from './creation-draft-submit'
const source: CreationDraftInput = {
id: 'draft',
title: 'Workspace',
text: 'Keep this source',
agent: 'codex',
executionHostId: 'local',
updatedAt: 1,
target: { worktreeId: 'workspace', terminalHandle: 'original', incarnationId: 'incarnation' }
}
let durable: CreationDraft | null
const deferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
async function save(input: CreationDraftInput, expected: number | null): Promise<CreationDraft> {
if ((durable?.revision ?? null) !== expected) {
throw new CreationDraftConflictError(durable)
}
durable = { ...input, revision: (expected ?? 0) + 1 }
return durable
}
const current = () => useCreationDraftSession.getState().entries[source.id]
beforeEach(async () => {
vi.resetAllMocks()
durable = null
useCreationDraftSession.setState({
entries: {},
loaded: true,
loadError: null,
viewedDraftId: null
})
database.saveDraft.mockImplementation(save)
delivery.sendCreationDraft.mockResolvedValue({ status: 'delivered' })
editCreationDraft(source)
await flushCreationDraft(source.id)
})
describe('creation draft explicit delivery', () => {
it('waits for the sending attempt to commit before invoking the sender', async () => {
const gate = deferred<void>()
database.saveDraft.mockImplementationOnce(async (input, revision) => {
await gate.promise
return save(input, revision)
})
const submitting = submitCreationDraft(source.id)
await vi.waitFor(() => expect(database.saveDraft).toHaveBeenCalledTimes(2))
expect(durable?.delivery).toBeUndefined()
expect(delivery.sendCreationDraft).not.toHaveBeenCalled()
gate.resolve()
expect(await submitting).toEqual({ status: 'delivered' })
expect(delivery.sendCreationDraft).toHaveBeenCalledExactlyOnceWith({
target: { executionHostId: 'local', ...source.target },
text: source.text
})
expect(durable).toMatchObject({
text: source.text,
delivery: { state: 'delivered', revision: 1 }
})
})
it('allows a deliberate retry after attempt persistence fails without sending any bytes', async () => {
database.saveDraft.mockRejectedValueOnce(new Error('disk unavailable'))
expect(await submitCreationDraft(source.id)).toEqual({ status: 'not-saved' })
expect(delivery.sendCreationDraft).not.toHaveBeenCalled()
expect(current().buffer).toMatchObject({ text: source.text })
expect(current().buffer.delivery).toBeUndefined()
expect(await submitCreationDraft(source.id)).toEqual({ status: 'delivered' })
expect(delivery.sendCreationDraft).toHaveBeenCalledOnce()
})
it('deduplicates simultaneous Send actions for one draft', async () => {
const sending = deferred<{ status: 'delivered' }>()
delivery.sendCreationDraft.mockReturnValue(sending.promise)
const first = submitCreationDraft(source.id)
const second = submitCreationDraft(source.id)
expect(first).toBe(second)
await vi.waitFor(() => expect(delivery.sendCreationDraft).toHaveBeenCalledOnce())
sending.resolve({ status: 'delivered' })
await Promise.all([first, second])
expect(await submitCreationDraft(source.id)).toEqual({ status: 'already-attempted' })
expect(delivery.sendCreationDraft).toHaveBeenCalledOnce()
})
it('keeps uncertain delivery and source without replaying after a transport error', async () => {
delivery.sendCreationDraft.mockRejectedValue(new Error('connection lost after write'))
expect(await submitCreationDraft(source.id)).toEqual({
status: 'uncertain',
reason: 'transport'
})
expect(durable).toMatchObject({ text: source.text, delivery: { state: 'uncertain' } })
expect(await submitCreationDraft(source.id)).toEqual({ status: 'already-attempted' })
expect(delivery.sendCreationDraft).toHaveBeenCalledOnce()
})
it('retains edits made while sending and never replaces them with the sent text', async () => {
const sending = deferred<{ status: 'delivered' }>()
delivery.sendCreationDraft.mockReturnValue(sending.promise)
const submitting = submitCreationDraft(source.id)
await vi.waitFor(() => expect(delivery.sendCreationDraft).toHaveBeenCalledOnce())
editCreationDraft({ ...current().buffer, text: 'Newer source' })
sending.resolve({ status: 'delivered' })
await submitting
expect(durable).toMatchObject({ text: 'Newer source', delivery: { state: 'delivered' } })
})
it('refuses sending when another window has already committed a delivery attempt', async () => {
durable = {
...durable!,
revision: 2,
delivery: { attemptId: 'other-window', revision: 1, state: 'sending' }
}
expect(await submitCreationDraft(source.id)).toEqual({ status: 'not-saved' })
expect(delivery.sendCreationDraft).not.toHaveBeenCalled()
expect(durable.delivery).toMatchObject({ attemptId: 'other-window', state: 'sending' })
expect(current().buffer.text).toBe(source.text)
})
it('requires a new Send when text changes during target resolution', async () => {
editCreationDraft({
...source,
target: { worktreeId: 'workspace', terminalHandle: 'original' }
})
await flushCreationDraft(source.id)
const capture = deferred<{ terminalHandle: string; incarnationId: string }>()
delivery.captureCreationDraftTarget.mockReturnValue(capture.promise)
const submitting = submitCreationDraft(source.id)
await vi.waitFor(() => expect(delivery.captureCreationDraftTarget).toHaveBeenCalledOnce())
editCreationDraft({ ...current().buffer, text: 'Edited during resolution' })
capture.resolve({ terminalHandle: 'original', incarnationId: 'incarnation' })
expect(await submitting).toEqual({ status: 'not-saved' })
await flushCreationDraft(source.id)
expect(delivery.sendCreationDraft).not.toHaveBeenCalled()
expect(durable?.text).toBe('Edited during resolution')
})
it('keeps the durable sending fence if recording the outcome fails', async () => {
delivery.sendCreationDraft.mockImplementation(async () => {
database.saveDraft.mockRejectedValue(new Error('disk unavailable'))
return { status: 'delivered' }
})
expect(await submitCreationDraft(source.id)).toEqual({ status: 'delivered' })
expect(durable).toMatchObject({ text: source.text, delivery: { state: 'sending' } })
database.saveDraft.mockImplementation(save)
expect(await submitCreationDraft(source.id)).toEqual({ status: 'already-attempted' })
expect(delivery.sendCreationDraft).toHaveBeenCalledOnce()
expect(durable).toMatchObject({ text: source.text, delivery: { state: 'delivered' } })
})
})
@@ -0,0 +1,102 @@
import { createBrowserUuid } from '../browser-uuid'
import {
captureCreationDraftTarget,
sendCreationDraft,
type CreationDraftDeliveryResult
} from './creation-draft-delivery'
import {
editCreationDraft,
flushCreationDraft,
useCreationDraftSession
} from './creation-draft-session'
export type CreationDraftSubmitResult =
| CreationDraftDeliveryResult
| { status: 'not-saved' | 'no-target' | 'already-attempted' }
const attempts = new Map<string, Promise<CreationDraftSubmitResult>>()
export function submitCreationDraft(id: string): Promise<CreationDraftSubmitResult> {
const existing = attempts.get(id)
if (existing) {
return existing
}
const attempt = submit(id).finally(() => {
attempts.delete(id)
})
attempts.set(id, attempt)
return attempt
}
async function submit(id: string): Promise<CreationDraftSubmitResult> {
await flushCreationDraft(id)
const entry = useCreationDraftSession.getState().entries[id]
if (
!entry ||
entry.error ||
entry.savedVersion !== entry.editVersion ||
entry.storedRevision === null
) {
return { status: 'not-saved' }
}
if (entry.buffer.delivery) {
return { status: 'already-attempted' }
}
const { buffer } = entry
if (!buffer.target?.terminalHandle) {
return { status: 'no-target' }
}
const baseTarget = {
executionHostId: buffer.executionHostId,
worktreeId: buffer.target.worktreeId,
terminalHandle: buffer.target.terminalHandle
}
const identity = buffer.target.incarnationId
? { terminalHandle: buffer.target.terminalHandle, incarnationId: buffer.target.incarnationId }
: await captureCreationDraftTarget(baseTarget)
if (!identity) {
return { status: 'no-target' }
}
// A new edit during target resolution requires another deliberate Send.
if (useCreationDraftSession.getState().entries[id]?.editVersion !== entry.editVersion) {
return { status: 'not-saved' }
}
const attemptId = createBrowserUuid()
const delivery = { attemptId, revision: entry.storedRevision, state: 'sending' as const }
editCreationDraft({
...buffer,
target: { ...buffer.target, ...identity },
delivery,
updatedAt: Date.now()
})
await flushCreationDraft(id)
const persisted = useCreationDraftSession.getState().entries[id]
if (
!persisted ||
persisted.error ||
persisted.savedVersion !== persisted.editVersion ||
persisted.buffer.delivery?.attemptId !== attemptId
) {
// No bytes were sent; clear only our unstarted attempt so saving can be retried.
if (persisted?.buffer.delivery?.attemptId === attemptId) {
editCreationDraft({ ...persisted.buffer, delivery: undefined, updatedAt: Date.now() })
await flushCreationDraft(id)
}
return { status: 'not-saved' }
}
let result: CreationDraftDeliveryResult
try {
result = await sendCreationDraft({ target: { ...baseTarget, ...identity }, text: buffer.text })
} catch {
result = { status: 'uncertain', reason: 'transport' }
}
const current = useCreationDraftSession.getState().entries[id]
if (current?.buffer.delivery?.attemptId === attemptId) {
editCreationDraft({
...current.buffer,
delivery: result.status === 'refused' ? undefined : { ...delivery, state: result.status },
updatedAt: Date.now()
})
await flushCreationDraft(id)
}
return result
}
@@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { useCreationDraftSession } from './workspace-creation-drafts/creation-draft-session'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
@@ -43,6 +44,7 @@ export async function completeWorktreeCreation(args: {
if (
!args.structuredLaunchAccepted &&
!request.suppressTerminalFocusOnCompletion &&
!useCreationDraftSession.getState().entries[args.creationId]?.buffer.text &&
args.focusOnCompletion
) {
queueWorkspaceActivationTerminalFocus(args.worktreeId, args.activation)
@@ -1,4 +1,5 @@
import { toast } from 'sonner'
import { bindCreationDraft } from './workspace-creation-drafts/creation-draft-session'
import { useAppStore } from '@/store'
import { preflightAgentTrust as preflightWorkspaceAgentTrust } from '@/lib/agent-trust-preflight'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
@@ -147,6 +148,15 @@ export async function executeWorktreeCreation(
(completionState.activeView === 'terminal' &&
completionState.activePendingCreationId === null))
bindCreationDraft(creationId, {
worktreeId: worktree.id,
...(result.startupTerminal?.handle ? { terminalHandle: result.startupTerminal.handle } : {}),
...(result.startupTerminal?.deferredStartup?.incarnationId
? { incarnationId: result.startupTerminal.deferredStartup.incarnationId }
: {}),
...(result.startupTerminal?.tabId ? { tabId: result.startupTerminal.tabId } : {})
})
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
if (shouldActivateOnCompletion) {
@@ -445,7 +445,7 @@ describe('staged background worktree creation', () => {
accountLabel: 'dev@company.test'
}
const request = makeRequest({ linkedWorkItem, linkedTaskSourceContext })
const expectedOptions = { linkedWorkItem, linkedTaskSourceContext }
const expectedOptions = { linkedWorkItem, linkedTaskSourceContext, callerOwnsCompletion: true }
expect(continueBackgroundWorktreeCreation('creation-1', request)).toBe(true)
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalledTimes(1))
@@ -741,6 +741,7 @@ describe('staged background worktree creation', () => {
)
const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined
expect(createCall?.[25]).toEqual({
callerOwnsCompletion: true,
startupDraft: 'https://github.com/o/r/issues/12'
})
})
@@ -1,5 +1,6 @@
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import { useAppStore } from '@/store'
import { useCreationDraftSession } from './workspace-creation-drafts/creation-draft-session'
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationPhase,
@@ -23,6 +24,7 @@ function revealPendingCreation(
phase: WorktreeCreationPhase
): void {
const store = useAppStore.getState()
useCreationDraftSession.setState({ viewedDraftId: null })
const indeterminate = getWorktreeCreationIndeterminate(request)
store.beginPendingWorktreeCreation({
creationId,
@@ -58,6 +60,7 @@ export function runBackgroundWorktreeCreation(
request
)
if (existingCreationId) {
useCreationDraftSession.setState({ viewedDraftId: null })
store.setActivePendingWorktreeCreation(existingCreationId)
store.setActiveView('terminal')
store.setSidebarOpen(true)
@@ -209,6 +209,52 @@ describe('worktree remote runtime mutations', () => {
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it.each([true, false])(
'negotiates caller-owned completion (supported: %s)',
async (supported) => {
const status = createCompatibleRuntimeStatusResponse('runtime-remote')
if (status.ok && !supported) {
status.result.capabilities = status.result.capabilities?.filter(
(capability) => capability !== 'worktree.caller-completion.v1'
)
}
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) =>
args.method === 'status.get' ? status : runtimeEnvironmentCall(args)
)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never
} as Partial<AppState>)
runtimeEnvironmentCall.mockResolvedValue({
id: 'created',
ok: true,
result: { worktree: makeWorktree({ id: 'repo1::/path/draft', repoId: 'repo1' }) },
_meta: { runtimeId: 'runtime-remote' }
})
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['repo1', 'draft']
args[16] = { command: 'codex' }
args[25] = { callerOwnsCompletion: true }
await createWorktree(...args)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'worktree.create',
params: expect.objectContaining(
supported
? { startupActivate: false, activate: false, awaitTerminalProvisioning: true }
: { startupCommand: 'codex', activate: true }
)
})
)
if (!supported) {
expect(runtimeEnvironmentCall.mock.calls[0][0].params).not.toHaveProperty(
'awaitTerminalProvisioning'
)
expect(runtimeEnvironmentCall.mock.calls[0][0].params).not.toHaveProperty('startupActivate')
}
}
)
it('passes startup commands through remote runtime worktree creation', async () => {
const store = createTestStore()
const wt = makeWorktree({
@@ -10,10 +10,14 @@ import {
} from '../../../../../../shared/new-workspace/worktree-create-retry-policy'
import {
assertRuntimeEnvironmentCapability,
runtimeEnvironmentSupportsCapability,
callRuntimeRpc,
getActiveRuntimeTarget
} from '../../../../runtime/runtime-rpc-client'
import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import {
WORKTREE_CALLER_COMPLETION_RUNTIME_CAPABILITY,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY
} from '../../../../../../shared/protocol-version'
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice'
import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast'
@@ -51,24 +55,44 @@ async function runCreateAttempt(
) {
throw new Error('deferred_startup_unavailable')
}
const callerOwnsCompletion =
Boolean(request.options?.callerOwnsCompletion) &&
(target.kind === 'local'
? !isWebClientLocation()
: await runtimeEnvironmentSupportsCapability(
target.environmentId,
WORKTREE_CALLER_COMPLETION_RUNTIME_CAPABILITY
).catch(() => false))
const provisionedRoot = request.options?.provisionedRoot
const create = async (
parentWorkspace: WorktreeCreateAttempt['parentWorkspace']
): Promise<CreateWorktreeResult> =>
provisionedRoot
? await window.api.worktrees.adoptProvisionedRoot({
...buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }),
...buildLocalWorktreeCreateArgs(
request,
{ ...attempt, parentWorkspace },
callerOwnsCompletion
),
...provisionedRoot
})
: target.kind === 'local'
? // Why local can still reject on the parent: paired web clients route this API to their host.
await window.api.worktrees.create(
buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace })
buildLocalWorktreeCreateArgs(
request,
{ ...attempt, parentWorkspace },
callerOwnsCompletion
)
)
: await callRuntimeRpc<CreateWorktreeResult>(
target,
'worktree.create',
buildRuntimeWorktreeCreateParams(request, { ...attempt, parentWorkspace }),
buildRuntimeWorktreeCreateParams(
request,
{ ...attempt, parentWorkspace },
callerOwnsCompletion
),
{ timeoutMs: 10 * 60_000 }
)
try {
@@ -21,6 +21,30 @@ describe('worktree startup activation intent', () => {
}
)
it('suppresses native activation only when completion owns the handoff', () => {
const request = { repoId: 'repo', name: 'draft', startup: { command: 'codex' } }
expect(buildLocalWorktreeCreateArgs(request, { name: 'draft' }, true).startup).toEqual({
command: 'codex',
activate: false
})
expect(buildLocalWorktreeCreateArgs(request, { name: 'draft' }).startup).toEqual({
command: 'codex'
})
})
it('waits for provisioning and suppresses host-built draft activation on capable runtimes', () => {
const request = { repoId: 'repo', name: 'draft', options: { startupDraft: 'task' } }
expect(buildRuntimeWorktreeCreateParams(request, { name: 'draft' }, true)).toMatchObject({
startupDraft: 'task',
startupActivate: false,
activate: false,
awaitTerminalProvisioning: true
})
expect(buildRuntimeWorktreeCreateParams(request, { name: 'draft' })).not.toHaveProperty(
'awaitTerminalProvisioning'
)
})
it('keeps ordinary foreground creation compatible when activation intent is absent', () => {
const payload = buildRuntimeWorktreeCreateParams(
{ repoId: 'repo', name: 'draft', startup: { command: 'codex' } },
@@ -6,6 +6,8 @@ import type { WorkspaceLinkedItem } from '../../../../../../shared/worktree/type
/** Trailing bag for `createWorktree` args that outgrew its positional list. */
export type CreateWorktreeCallOptions = {
/** Creation completion binds the draft before revealing its terminal. */
callerOwnsCompletion?: boolean
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null
@@ -100,19 +102,25 @@ function sharedCreateFields(
export function buildLocalWorktreeCreateArgs(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
attempt: WorktreeCreateAttempt,
callerOwnsCompletion = false
): CreateWorktreeArgs {
return {
repoId: request.repoId,
...sharedCreateFields(request, attempt),
...(request.startup ? { startup: request.startup } : {}),
...(request.startup
? {
startup: callerOwnsCompletion ? { ...request.startup, activate: false } : request.startup
}
: {}),
...(request.creationId ? { creationId: request.creationId } : {})
}
}
export function buildRuntimeWorktreeCreateParams(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
attempt: WorktreeCreateAttempt,
callerOwnsCompletion = false
): Record<string, unknown> {
const { startup, options } = request
return {
@@ -132,6 +140,9 @@ export function buildRuntimeWorktreeCreateParams(
activate: startup.activate !== false,
...(startup.activate !== undefined ? { startupActivate: startup.activate } : {})
}
: {}),
...(callerOwnsCompletion
? { startupActivate: false, activate: false, awaitTerminalProvisioning: true }
: {})
}
}
+6
View File
@@ -39,6 +39,8 @@ export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as
export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const
export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const
export const WORKTREE_BACKGROUND_STARTUP_CAPABILITY = 'worktree.background-startup.v1' as const
export const WORKTREE_CALLER_COMPLETION_RUNTIME_CAPABILITY =
'worktree.caller-completion.v1' as const
export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY =
'worktree.linked-work-item-context.v1' as const
export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY =
@@ -96,6 +98,8 @@ export const BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY =
// floor-taking input. Mobile must not forward replies unless advertised.
export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY =
'terminal.query-reply-input.v1' as const
// Fences terminal.send against runtime-owned incarnation evidence; not a relay write guarantee.
export const TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY = 'terminal.send-incarnation.v1' as const
// Why: paired clients may unmount xterm only when the host can return a
// bounded, sequenced scrollback snapshot for lossless reveal.
export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const
@@ -212,6 +216,7 @@ export const RUNTIME_CAPABILITIES = [
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
WORKTREE_BACKGROUND_STARTUP_CAPABILITY,
WORKTREE_CALLER_COMPLETION_RUNTIME_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
@@ -219,6 +224,7 @@ export const RUNTIME_CAPABILITIES = [
AI_VAULT_RUNTIME_CAPABILITY,
AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY,
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
TERMINAL_SEND_INCARNATION_RUNTIME_CAPABILITY,
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,