From 8e63ae4ada44240e1a8bc8a9c5d009697cfbf276 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:56:45 -0700 Subject: [PATCH] feat: preserve editable prompts through workspace creation --- ...ged-worktree-create-execution-host.test.ts | 38 +++ .../orca-runtime-create-managed-worktree.ts | 6 +- .../rpc/methods/worktree-create-args.test.ts | 21 ++ .../rpc/methods/worktree-create-args.ts | 4 + .../rpc/methods/worktree-create-schemas.ts | 1 + .../runtime/runtime-folder-worktree-create.ts | 4 +- .../runtime-folder-worktree-startup.test.ts | 80 +++++ .../runtime-managed-worktree-create-types.ts | 1 + ...untime-worktree-background-startup.test.ts | 33 ++ .../src/app-shell/AppWorkspaceShell.tsx | 74 ++--- .../components/terminal-pane/pane-helpers.ts | 2 +- ...terminal-pane-layout-restore-focus.test.ts | 103 +++++++ .../terminal-pane-layout-restore.ts | 8 +- .../worktree-creation/CreationDraftEditor.tsx | 222 ++++++++++++++ .../CreationDraftSurface.tsx | 140 +++++++++ .../src/lib/create-requested-worktree.test.ts | 3 +- .../src/lib/create-requested-worktree.ts | 1 + .../creation-draft-connection.ts | 124 ++++++++ .../creation-draft-copy.test.ts | 189 ++++++++++++ .../creation-draft-database.ts | 118 ++++++++ .../creation-draft-delivery.test.ts | 223 ++++++++++++++ .../creation-draft-delivery.ts | 201 +++++++++++++ .../creation-draft-readiness.test.ts | 71 +++++ .../creation-draft-readiness.ts | 30 ++ .../creation-draft-record.test.ts | 54 ++++ .../creation-draft-record.ts | 52 ++++ .../creation-draft-session.test.ts | 112 +++++++ .../creation-draft-session.ts | 283 ++++++++++++++++++ .../creation-draft-submit.test.ts | 177 +++++++++++ .../creation-draft-submit.ts | 102 +++++++ .../src/lib/worktree-creation-completion.ts | 2 + .../src/lib/worktree-creation-flow-execute.ts | 10 + .../src/lib/worktree-creation-flow.test.ts | 3 +- .../src/lib/worktree-creation-flow.ts | 3 + .../worktrees-remote-runtime-create.test.ts | 46 +++ .../worktrees/create/create-worktree.ts | 32 +- .../create/worktree-create-payload.test.ts | 24 ++ .../create/worktree-create-payload.ts | 17 +- src/shared/protocol-version.ts | 6 + 39 files changed, 2571 insertions(+), 49 deletions(-) create mode 100644 src/main/runtime/runtime-folder-worktree-startup.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-layout-restore-focus.test.ts create mode 100644 src/renderer/src/components/worktree-creation/CreationDraftEditor.tsx create mode 100644 src/renderer/src/components/worktree-creation/CreationDraftSurface.tsx create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-connection.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-copy.test.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-database.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-delivery.test.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-delivery.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-readiness.test.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-readiness.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-record.test.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-record.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-session.test.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-session.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-submit.test.ts create mode 100644 src/renderer/src/lib/workspace-creation-drafts/creation-draft-submit.ts diff --git a/src/main/runtime/managed-worktree-create-execution-host.test.ts b/src/main/runtime/managed-worktree-create-execution-host.test.ts index af1a73dc9cc..ab44d0c32f8 100644 --- a/src/main/runtime/managed-worktree-create-execution-host.test.ts +++ b/src/main/runtime/managed-worktree-create-execution-host.test.ts @@ -37,6 +37,7 @@ const TARGET_ID = 'remote-1' const REMOTE_PATH = '/srv/app' type RuntimeInternals = { + buildStartupForDraft: (...args: unknown[]) => Promise resolveRepoSelector: (selector: string) => Promise createManagedRemoteWorktree: (repo: unknown, args: unknown) => Promise resolveLineageForWorktreeCreate: (input: unknown) => Promise @@ -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') + } + ) }) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index f17a2285b83..3b2b659d9f5 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index 0379c7253ad..95d853dc8a3 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -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() diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 93ef3e7667e..c341fc660ef 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index 6920aeff9d4..30fcb38e28e 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -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(), diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index efea3c22c72..7c85e454468 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-folder-worktree-startup.test.ts b/src/main/runtime/runtime-folder-worktree-startup.test.ts new file mode 100644 index 00000000000..44f1a4f6a92 --- /dev/null +++ b/src/main/runtime/runtime-folder-worktree-startup.test.ts @@ -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') + } + ) +}) diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 17c8dc743db..6f96f310891 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -55,6 +55,7 @@ export type RuntimeManagedWorktreeCreateArgs = { automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance creatorProvenance?: Worktree['creatorProvenance'] + startupActivate?: boolean startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste diff --git a/src/main/runtime/runtime-worktree-background-startup.test.ts b/src/main/runtime/runtime-worktree-background-startup.test.ts index 84f3d772544..346aadb4e3b 100644 --- a/src/main/runtime/runtime-worktree-background-startup.test.ts +++ b/src/main/runtime/runtime-worktree-background-startup.test.ts @@ -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 }) diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index 9ea9780a5c3..6c621a25c12 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -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: { )}
- {layout.shouldMountTerminalWorkbench ? ( - - - - - - - - ) : null} - - - - - +
+ {layout.shouldMountTerminalWorkbench ? ( + + + + + + + + ) : null} + + + + + +
+
{floatingWorkspace.showToggleButton ? ( 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[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 }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts index 8aa2664dc76..fa7c19a8152 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts @@ -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 { 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) diff --git a/src/renderer/src/components/worktree-creation/CreationDraftEditor.tsx b/src/renderer/src/components/worktree-creation/CreationDraftEditor.tsx new file mode 100644 index 00000000000..a138036914e --- /dev/null +++ b/src/renderer/src/components/worktree-creation/CreationDraftEditor.tsx @@ -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 +}): 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(null) + const atCapacity = useCreationDraftSession( + (state) => + !state.entries[initial.id] && Object.keys(state.entries).length >= CREATION_DRAFT_LIMIT + ) + const send = async (): Promise => { + 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 => { + try { + await window.api.ui.writeClipboardText(buffer.text) + } catch { + toast.error(translate('creationDraft.copyFailed', 'Could not copy the draft.')) + } + } + const saveCopy = async (): Promise => { + 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 => { + 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 ( +
+