mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat: preserve editable prompts through workspace creation
This commit is contained in:
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user