From bbb3e7e5ee4a7c861d2f0e3a17b9b72be77d409d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:08:56 -0700 Subject: [PATCH] fix(native-chat): mirror multi-line launch drafts into the chat composer (#11253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): mirror multi-line launch drafts into the chat composer seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline, so every Linear launch ("Linked Linear issue: X\n") and any GitHub launch with a typed note was invisible in chat. The rejection existed because the send path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with embedded newlines. Orca injects the draft itself, so when the composer still holds exactly what was injected the buffer already IS the message: the send becomes the submit key alone — no clear, no paste, nothing that can concatenate, and multi-line submits as one turn for free. Only the edited case needs real buffer replacement, and that now clears every line and verifies against the agent's rendered input line instead of firing blind. Measured on real PTYs against Claude Code and codex (both agree exactly): clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts for the law, the sequences that do NOT work, and why an upper bound is safe. * fix(native-chat): send the mobile clear burst as its own write Live QA caught the bundled form failing: a multi-line burst prefixed onto the body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters, so the parked draft survived and the message arrived as draft + 21x \x15 + body. Sending the burst as its own non-submitting write — the shape the image paste has always used — clears as intended. The body write's own single-Ctrl+U prefix is dropped once that dedicated clear ran, for the same reason: a Ctrl+U immediately followed by body text in one write lands as a literal control character and headed the received message. Re-verified live end to end: received prompt is exactly the draft, one turn, zero control characters. * test(native-chat): invert the multi-line Linear launch-draft mirror expectation The Linear work-item launch seeds `Linked Linear issue: ENG-42\n\n`. This test pinned the pre-relaxation rule (multi-line drafts withheld), which the send path no longer needs now that it submits the TUI buffer in place or clears every line first — so it asserted the exact behavior the fix removes. Assert the seeded payload instead of absence, so the test fails if the mirror regresses to single-line-only. * fix(native-chat): preserve launch draft send contents * fix(native-chat): preserve confirmed send queue ordering * fix(native-chat): preserve send pacing after renderer stalls * test(native-chat): align activation with multiline draft mirroring * fix(native-chat): clear launch drafts from any cursor * fix(native-chat): retire mobile-consumed launch drafts * test(mobile): stabilize QR capacity boundary fixture --- .../app/h/[hostId]/session/[worktreeId].tsx | 1 + .../session/mobile-session-route-types.ts | 1 + .../session/mobile-native-chat-eligibility.ts | 1 + .../mobile-native-chat-image-send.test.ts | 50 +++++ .../session/mobile-native-chat-image-send.ts | 9 +- .../session/mobile-native-chat-send.test.ts | 84 ++++++++ mobile/src/session/mobile-native-chat-send.ts | 47 +++++ .../session/mobile-terminal-records.test.ts | 4 +- mobile/src/session/mobile-terminal-records.ts | 2 + .../use-mobile-native-chat-controller.test.ts | 4 + .../use-mobile-native-chat-controller.ts | 10 +- ...le-native-chat-drafts-launch-draft.test.ts | 18 ++ .../session/use-mobile-native-chat-drafts.ts | 76 ++----- ...bile-native-chat-image-attachments.test.ts | 36 ++++ ...se-mobile-native-chat-image-attachments.ts | 13 +- ...se-mobile-native-chat-launch-draft-seed.ts | 122 +++++++++++ ...se-mobile-native-chat-message-send.test.ts | 176 ++++++++++++++++ .../use-mobile-native-chat-message-send.ts | 53 ++++- .../use-mobile-session-image-attachments.ts | 5 + src/main/runtime/mobile-pairing-qr.test.ts | 5 +- src/main/runtime/orca-runtime.test.ts | 125 +++++++++++ src/main/runtime/orca-runtime.ts | 163 ++++++++++++++- ...ime-request-connection.integration.test.ts | 13 ++ src/main/runtime/rpc/methods/client-events.ts | 4 + src/main/runtime/rpc/methods/terminal.ts | 15 ++ ...minal-send-launch-draft-resolution.test.ts | 67 ++++++ .../window/attach-main-window-services.ts | 2 + src/preload/api-types.ts | 3 + src/preload/index.ts | 10 + .../native-chat/NativeChatComposer.tsx | 21 +- .../native-chat-launch-draft-send.test.ts | 111 ++++++++++ .../native-chat-launch-draft-send.ts | 99 +++++++++ .../native-chat/native-chat-pty-send-queue.ts | 39 ++-- ...ive-chat-runtime-send-launch-draft.test.ts | 195 ++++++++++++++++++ .../native-chat-runtime-send.test.ts | 17 ++ .../native-chat/native-chat-runtime-send.ts | 143 +++++++++---- ...native-chat-launch-draft-adoption.test.tsx | 11 + .../use-native-chat-launch-draft-adoption.ts | 5 +- .../use-native-chat-send-lifecycle.test.tsx | 26 +++ .../use-native-chat-send-lifecycle.ts | 25 ++- .../folder-workspace-composer-submit.test.ts | 10 +- src/renderer/src/hooks/useIpcEvents.ts | 17 ++ .../lib/agent-launch-prompt-delivery.test.ts | 34 +-- .../src/lib/launch-agent-in-new-tab.test.ts | 13 +- .../src/lib/launch-work-item-direct.test.ts | 14 +- .../lib/native-chat-initial-view-mode.test.ts | 16 +- ...ve-chat-launch-draft-mirrorability.test.ts | 21 +- .../native-chat-launch-draft-mirrorability.ts | 18 +- .../src/lib/native-chat-launch-prompt.ts | 2 + .../src/lib/worktree-activation.test.ts | 11 +- .../lib/worktree-creation-agent-seeds.test.ts | 2 +- .../src/lib/worktree-creation-flow.test.ts | 2 +- ...at-launch-draft-runtime-resolution.test.ts | 18 ++ ...ve-chat-launch-draft-runtime-resolution.ts | 19 ++ .../src/runtime/runtime-client-events.test.ts | 17 +- .../src/runtime/runtime-client-events.ts | 1 + .../src/runtime/sync-runtime-graph.test.ts | 31 ++- .../src/runtime/sync-runtime-graph.ts | 29 ++- .../native-chat-launch-draft-teardown.test.ts | 15 ++ src/renderer/src/store/slices/terminals.ts | 24 +++ src/renderer/src/web/web-preload-api.ts | 1 + src/shared/agent-tui-input-clear.test.ts | 87 ++++++++ src/shared/agent-tui-input-clear.ts | 45 ++++ src/shared/runtime-client-events.ts | 2 + src/shared/runtime-types.ts | 9 + 65 files changed, 2076 insertions(+), 193 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts create mode 100644 mobile/src/session/use-mobile-native-chat-message-send.test.ts create mode 100644 src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts create mode 100644 src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts create mode 100644 src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts create mode 100644 src/shared/agent-tui-input-clear.test.ts create mode 100644 src/shared/agent-tui-input-clear.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7e4ac1793..0eb894a48 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -3675,6 +3675,7 @@ export default function SessionScreen() { getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, showToast, onNativeChatSendError: nativeChatSendError.show, onSuccess: triggerSelection, diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts index 219dd58dc..c5a94e9d3 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -28,6 +28,7 @@ export type MobileSessionTab = launchAgent?: TuiAgent /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number terminalTheme?: MobileTerminalTheme isActive: boolean } diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 988e56c67..2f997c2cd 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -27,6 +27,7 @@ export type MobileNativeChatTab = { agentStatus?: AgentStatusEntry | null /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number } /** Resolve a session tab to the transcript identity native chat needs, or diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index c987e3f97..46ac4730f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' function sendResult(accepted: boolean, id = 'send'): RpcSuccess { return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } } @@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => { } }) }) + +describe('clearing a parked multi-line launch draft before the image paste', () => { + it('leads with the caller-sized burst instead of one Ctrl+U', async () => { + // One Ctrl+U kills only the LAST line, so the draft's earlier lines would + // survive and ride along with the image as part of the prompt body. + const client = clientWithResponses([sendResult(true), sendResult(true)]) + const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png'], + clearInput + }) + + expect(client.calls[0]?.params.text).toBe(clearInput) + expect(client.calls[0]?.params.text).not.toBe('\x15') + }) + + it('clears once, before the paste — never between or after the image writes', async () => { + const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const clearInput = buildAgentTuiClearInputForText('a\nb\nc') + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png', '/tmp/b.png'], + clearInput + }) + + expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1) + expect(client.calls[0]?.params.text).toBe(clearInput) + }) + + it('falls back to a single Ctrl+U when no draft is parked', async () => { + const client = clientWithResponses([sendResult(true), sendResult(true)]) + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png'] + }) + + expect(client.calls[0]?.params.text).toBe('\x15') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index 4d52c59b0..adb996b76 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -26,6 +26,10 @@ type PasteImagesArgs = { /** Budget shared with the rest of the user action (the text body that follows, or * the send this is healing for). Omit to open a fresh one for this paste alone. */ readonly deadline?: number + /** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only + * ONE logical line — callers holding a parked multi-line launch draft must + * pass a burst, or its earlier lines survive and glue onto the message. */ + readonly clearInput?: string } /** Clears the agent's unsubmitted input line, then pastes each uploaded image @@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({ terminal, deviceToken, imagePaths, - deadline: sharedDeadline + deadline: sharedDeadline, + clearInput }: PasteImagesArgs): Promise { const mobileClient: MobileTerminalClient | null = deviceToken ? { id: deviceToken, type: 'mobile' } @@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({ // once and let each write draw from what's left. const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() for (const text of [ - MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...imagePaths.map(buildMobileImagePastePayload) ]) { const remainingMs = deadline - Date.now() diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 1f3d57985..ccdacfd7b 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, openMobileNativeChatSendBudget, + clearMobileNativeChatInput, sendMobileNativeChatMessage, sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' function clientWithResponse(response: unknown): RpcClient { return { @@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => { client, terminal: 'term', text: 'hello', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 }, mobileClient: { id: 'device', type: 'mobile' } }) ).resolves.toBe(true) @@ -38,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => { terminal: 'term', text: 'hello', enter: true, + resolvedLaunchDraft: { text: 'seed', createdAt: 7 }, client: { id: 'device', type: 'mobile' } }, { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } @@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => { expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS) }) }) + +describe('clearMobileNativeChatInput', () => { + const accepted = { + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } + const params = (client: RpcClient) => + vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean } + + it('writes the burst as its OWN non-submitting write', async () => { + // Bundling the burst into the body write reached the agent as LITERAL Ctrl+U + // text and the parked draft concatenated (observed live). + const client = clientWithResponse(accepted) + const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') + await expect( + clearMobileNativeChatInput({ client, terminal: 'term', clearInput }) + ).resolves.toBe(true) + expect(params(client)).toMatchObject({ text: clearInput, enter: false }) + }) + + it('reports failure when the host rejects the clear', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await expect( + clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' }) + ).resolves.toBe(false) + }) + + it('refuses to start an underfunded clear rather than half-clearing', async () => { + const client = clientWithResponse(accepted) + await expect( + clearMobileNativeChatInput({ + client, + terminal: 'term', + clearInput: '\x15', + deadline: Date.now() + 10 + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) + +describe('the body write never carries a multi-line burst', () => { + const accepted = { + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } + const sentText = (client: RpcClient): string => + (vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text + + it('still prefixes only a single Ctrl+U when asked to clear first', async () => { + const client = clientWithResponse(accepted) + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + clearInputFirst: true + }) + expect(sentText(client)).toBe('\x15hello') + }) + + it('never prefixes a clear when the caller already pasted (image sends)', async () => { + const client = clientWithResponse(accepted) + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'caption', + clearInputFirst: false + }) + expect(sentText(client)).toBe('caption') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ec8a0f978..ce432c3ef 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -11,6 +11,11 @@ type MobileTerminalClient = { // Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the // same byte before its body), so a launch-context prefill parked there cannot // concatenate with a mobile chat message. The host writes text bytes verbatim. +// +// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked +// launch draft is routinely multi-line (every Linear block is); callers that know +// one is parked must call clearMobileNativeChatInput FIRST — see +// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law. const CLEAR_UNSUBMITTED_INPUT = '\x15' type MobileNativeChatSendArgs = { @@ -19,6 +24,8 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean + /** Exact host launch draft this submitting write resolves when accepted. */ + resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient /** Shared budget for a whole user action (heal → paste → text, or one selector's * keystroke sequence). Omit to give this write its own full budget. */ @@ -59,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, + ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, // The budget covers this whole write, reconnect wait included — a chat send @@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage( ): Promise { return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } + +/** + * Clear the agent's input line as its OWN write, before any body. + * + * Why not prefix it onto the body write: a multi-line clear burst bundled into + * the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U + * characters — the draft survived and the burst landed in the middle of the + * message (observed live: draft + 21 literal \x15 + body). A standalone write is + * the shape the image paste has always used, and it clears as intended. + */ +export async function clearMobileNativeChatInput(args: { + client: RpcClient + terminal: string + clearInput: string + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + const timeoutMs = + args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() + if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { + return false + } + try { + const response = await args.client.sendRequest( + 'terminal.send', + { + terminal: args.terminal, + text: args.clearInput, + enter: false, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }, + { timeoutMs, budgetSpansConnect: true } + ) + return isTerminalSendRpcAccepted(response) + } catch { + // A failed clear must not send the body on top of an uncleared line. + return false + } +} diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index fba077426..cdcbfaafa 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -102,12 +102,14 @@ describe('mobile terminal records', () => { } const seeded: MobileTerminalSessionTab = { ...base, - launchDraft: 'https://github.com/o/r/issues/12' + launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 1 } expect(mobileSessionTabsEqual([base], [seeded])).toBe(false) expect(mobileSessionTabsEqual([seeded], [base])).toBe(false) expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true) + expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false) }) it('treats terminal agent-status changes as session-tab changes', () => { diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 07255ffbf..4a9e4438c 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -19,6 +19,7 @@ export type MobileTerminalSessionTab = { agentStatus?: AgentStatusEntry | null /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number terminalTheme?: MobileTerminalTheme isActive: boolean } @@ -89,6 +90,7 @@ function mobileSessionTabEqual( // A frame whose only delta is the launch draft appearing or retracting // still has to reach the chat composer. a.launchDraft === b.launchDraft && + a.launchDraftCreatedAt === b.launchDraftCreatedAt && JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) && JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null) ) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index db285aecb..2b4fb0e9d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ setComposerText: vi.fn(), pending: [], captureSendOrigin, + readSeededLaunchDraft: () => null, + readSeededLaunchDraftSeed: () => null, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -327,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { terminal: 'term-1', launchAgent: 'claude', launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 7, isActive: true } @@ -385,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { expect(draftsArgs.at(-1)).toMatchObject({ tabId: 'tab-1', launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 7, chatActive: true, transcriptLoading: false }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 255e28981..fd0a564b1 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -73,6 +73,9 @@ export type MobileNativeChatController = { images?: string[], deadline?: number ) => Promise + /** Launch-context text still parked on the agent's TUI input line, or null. + * Image sends read it to size their leading clear (one Ctrl+U per line). */ + readSeededLaunchDraft: () => string | null } /** Owns mobile native-chat state and teardown outside the already dense session @@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: { setComposerText: setChatComposerText, pending: chatPending, captureSendOrigin, + readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -145,6 +150,7 @@ export function useMobileNativeChatController(args: { sessionId: activeChatSessionId, messages: nativeChatSession.messages, launchDraft: activeSessionTab?.launchDraft ?? null, + launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null, // Why: pass the raw draft plus this flag rather than nulling it off-chat — // a null is indistinguishable from a host retraction, and peeking at the // terminal view would permanently decline the prefill. @@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: { handleRef: activeHandleRef, deviceTokenRef, captureSendOrigin, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: { loadNativeChatFiles, handleNativeChatQuestionAnswer, handleNativeChatSend, - handleNativeChatSendWithOutcome + handleNativeChatSendWithOutcome, + readSeededLaunchDraft } } diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 3b830c038..91c84814c 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId = `session-${tabId}`, messages = [], launchDraft = null, + launchDraftCreatedAt = null, chatActive = true, transcriptLoading = false }: { @@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId?: string | null messages?: NativeChatMessage[] launchDraft?: string | null + launchDraftCreatedAt?: number | null chatActive?: boolean transcriptLoading?: boolean }): null { @@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId, messages, launchDraft, + launchDraftCreatedAt, chatActive, transcriptLoading }) @@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => { expect(state?.composerText).toBe('') }) + it('captures the generation paired with the adopted text', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + launchDraftCreatedAt: 7 + }) + ) + ) + + expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 }) + }) + it('does not overwrite typed composer text with a launch draft', async () => { await mount('a') act(() => state?.setComposerText('typed first')) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 79b82ad5c..a43bc8b28 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -8,6 +8,8 @@ import { type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' export type MobileNativeChatPendingMessage = { id: string @@ -43,6 +45,7 @@ export function useMobileNativeChatDrafts(args: { messages: readonly NativeChatMessage[] /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string | null + launchDraftCreatedAt?: number | null /** Whether the tab is currently resolved to the chat view. Off-chat the * launch-draft effects hold their state instead of acting on it. */ chatActive?: boolean @@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: { setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + /** Launch-context text still believed to be parked on the agent's TUI input + * line, or null once it has been declined or retired. Send paths size their + * pre-clear from it, since one Ctrl+U clears only one logical line. */ + readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null /** Clear the composer at send time, before the RPC settles. */ clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void /** Put the text back after a definite rejection, unless newer edits exist. */ @@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: { sessionId, messages, launchDraft, + launchDraftCreatedAt, chatActive = true, transcriptLoading } = args @@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) - // Seeded launch-context text per tab; '' marks a permanent decline so a - // cleared composer never resurrects the prefill. - const seededLaunchDraftByKeyRef = useRef(new Map()) - - // Why: launch context delivered as a TUI-input prefill is invisible in chat; - // adopt it once as the composer draft so mobile shows the same context. - useEffect(() => { - if ( - !draftKey || - !chatActive || - !launchDraft?.trim() || - seededLaunchDraftByKeyRef.current.has(draftKey) - ) { - return - } - // Why: `session.tabs` carries launchDraft before the transcript read settles, - // and an empty (or previous tab's) list would let the decline below misjudge - // an already-submitted prefill — long enough for a send to duplicate it. - if (transcriptLoading) { - return - } - // A user turn already in the transcript means the one-line TUI prefill was - // submitted or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { - seededLaunchDraftByKeyRef.current.set(draftKey, '') - return - } - seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) - setDrafts((previous) => - (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous - ) - }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) - - // Drop an untouched adopted copy once the prefill is resolved elsewhere — a - // user turn landed (sent or cleared TUI-side) or the host stopped publishing - // it (desktop sent or reconciled it). User edits are always kept. - useEffect(() => { - // Same gates as the seed: off-chat there is no retraction to read (the tab - // publishes no draft to us), and an untrusted transcript would wipe an - // untouched copy on the strength of another tab's user turns. - if (!draftKey || !chatActive || transcriptLoading) { - return - } - const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) - if (!seeded) { - return - } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) - if (!hasUserTurn && launchDraft?.trim()) { - return - } - seededLaunchDraftByKeyRef.current.set(draftKey, '') - setDrafts((previous) => - (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous - ) - }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ + draftKey, + messages, + launchDraft, + launchDraftCreatedAt, + chatActive, + transcriptLoading, + setDrafts + }) const setComposerText: Dispatch> = useCallback( (value) => { @@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: { setComposerText, pending, captureSendOrigin, + readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 68ae3e2dc..be2f9a373 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -1,5 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' @@ -69,6 +70,7 @@ function baseArgs(overrides: Partial & Pick): Hook showToast: vi.fn(), onSendError: vi.fn(), baseSend: vi.fn().mockResolvedValue('accepted'), + readSeededLaunchDraft: () => null, sleep: async () => {}, ...overrides } @@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.attachments).toEqual([]) }) + it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { + // A single Ctrl+U kills only the last line, so the draft's earlier lines + // would survive the clear and ride along with the image as prompt body. + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), + sendResult(true) + ]) + const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + mount( + baseArgs({ + client: client as unknown as RpcClient, + deviceTokenRef: { current: 'device-1' }, + readSeededLaunchDraft: () => draft + }) + ) + + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('look at this') + }) + + const firstSend = client.calls.find((c) => c.method === 'terminal.send') + expect(firstSend?.params).toMatchObject({ + text: buildAgentTuiClearInputForText(draft), + enter: false + }) + expect(firstSend?.params.text).not.toBe('\x15') + }) + it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 7ce1ad897..a692b11ac 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { @@ -56,6 +57,10 @@ type Args = { imagePreviewUris?: string[], deadline?: number ) => Promise + /** Launch-context text parked on the agent's TUI input line, or null. The + * paste's leading clear must cover every line of it, or the draft's earlier + * lines survive and ride along with the image. */ + readonly readSeededLaunchDraft: () => string | null readonly onAttachSuccess?: () => void readonly onError?: () => void // Injected so the settle between image paste and submit is instant in tests. @@ -106,6 +111,7 @@ export function useMobileNativeChatImageAttachments({ showToast, onSendError, baseSend, + readSeededLaunchDraft, onAttachSuccess, onError, sleep = defaultSleep @@ -269,12 +275,16 @@ export function useMobileNativeChatImageAttachments({ return false } try { + const seededLaunchDraft = readSeededLaunchDraft() const pasted = await pasteMobileNativeChatImagePaths({ client, terminal: handle, deviceToken: deviceTokenRef.current, imagePaths: pendingImages.map((attachment) => attachment.path), - deadline + deadline, + ...(seededLaunchDraft + ? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) } + : {}) }) if (!pasted) { // Keep the chips so the user can retry; the failed paste never submitted. @@ -351,6 +361,7 @@ export function useMobileNativeChatImageAttachments({ enabled, onError, onSendError, + readSeededLaunchDraft, scopeKey, sleep ] diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts new file mode 100644 index 000000000..b75dbf127 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { normalizedUserText } from './mobile-native-chat-draft-reconcile' + +export type MobileNativeChatLaunchDraftSeed = { + text: string + createdAt: number | null +} + +/** + * Adopting the host's launch-context prefill as the mobile composer draft, and + * retiring it again once it is resolved elsewhere. Split out of the drafts hook + * so the general draft/pending accounting stays separate from this one concern. + */ +export function useMobileNativeChatLaunchDraftSeed(args: { + draftKey: string | null + messages: readonly NativeChatMessage[] + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string | null + launchDraftCreatedAt?: number | null + chatActive: boolean + transcriptLoading?: boolean + setDrafts: Dispatch>> +}): { + /** Text still believed to be parked on the agent's TUI input line, or null + * once declined or retired. Send paths size their pre-clear from it, since + * one Ctrl+U clears only one logical line. */ + readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null +} { + const { + draftKey, + messages, + launchDraft, + launchDraftCreatedAt, + chatActive, + transcriptLoading, + setDrafts + } = args + + // Seeded launch-context text per tab; null marks a permanent decline so a + // cleared composer never resurrects the prefill. + const seededLaunchDraftByKeyRef = useRef( + new Map() + ) + + // Why: launch context delivered as a TUI-input prefill is invisible in chat; + // adopt it once as the composer draft so mobile shows the same context. + useEffect(() => { + if ( + !draftKey || + !chatActive || + !launchDraft?.trim() || + seededLaunchDraftByKeyRef.current.has(draftKey) + ) { + return + } + // Why: `session.tabs` carries launchDraft before the transcript read settles, + // and an empty (or previous tab's) list would let the decline below misjudge + // an already-submitted prefill — long enough for a send to duplicate it. + if (transcriptLoading) { + return + } + // A user turn already in the transcript means the TUI prefill was submitted + // or deliberately cleared; decline instead of resurrecting it. + if (messages.some((message) => normalizedUserText(message) !== null)) { + seededLaunchDraftByKeyRef.current.set(draftKey, null) + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, { + text: launchDraft, + createdAt: launchDraftCreatedAt ?? null + }) + setDrafts((previous) => + (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous + ) + }, [ + chatActive, + draftKey, + launchDraft, + launchDraftCreatedAt, + messages, + setDrafts, + transcriptLoading + ]) + + // Drop an untouched adopted copy once the prefill is resolved elsewhere — a + // user turn landed (sent or cleared TUI-side) or the host stopped publishing + // it (desktop sent or reconciled it). User edits are always kept. + useEffect(() => { + // Same gates as the seed: off-chat there is no retraction to read (the tab + // publishes no draft to us), and an untrusted transcript would wipe an + // untouched copy on the strength of another tab's user turns. + if (!draftKey || !chatActive || transcriptLoading) { + return + } + const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) + if (!seeded) { + return + } + const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + if (!hasUserTurn && launchDraft?.trim()) { + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, null) + setDrafts((previous) => + (previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading]) + + // A missing or declined entry means there is nothing of ours on the TUI line. + const readSeededLaunchDraft = useCallback( + () => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null), + [draftKey] + ) + const readSeededLaunchDraftSeed = useCallback( + () => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null), + [draftKey] + ) + + return { readSeededLaunchDraft, readSeededLaunchDraftSeed } +} diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts new file mode 100644 index 000000000..da36cdb92 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -0,0 +1,176 @@ +// Covers the wiring the image-attachments suite structurally cannot: that hook +// injects its own baseSend stub, so it never observes the real send params. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendWithOutcome = vi.fn() +const clearInputWrite = vi.fn() +vi.mock('./mobile-native-chat-send', () => ({ + sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), + openMobileNativeChatSendBudget: () => Date.now() + 15_000, + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, + MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000 +})) +vi.mock('./mobile-native-chat-stale-input', () => ({ + healMobileNativeChatStaleInput: () => Promise.resolve(true) +})) + +import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' + +type Send = ReturnType + +const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + +describe('useMobileNativeChatMessageSend', () => { + let renderer: ReactTestRenderer | null = null + let api: Send | null = null + + const mount = ( + readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null + ): void => { + function Probe(): null { + api = useMobileNativeChatMessageSend({ + client: { sendRequest: vi.fn() } as never, + enabled: true, + handleRef: { current: 'term' }, + deviceTokenRef: { current: 'device' }, + captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, + readSeededLaunchDraftSeed, + clearDraftForSend: () => {}, + restoreRejectedDraft: () => {}, + acceptSend: () => {}, + holdUnconfirmedSend: () => {}, + onSendError: () => {} + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + } + + const sentArgs = (): { + clearInputFirst?: boolean + resolvedLaunchDraft?: { text: string; createdAt: number } + } => + sendWithOutcome.mock.calls[0]![0] as { + clearInputFirst?: boolean + resolvedLaunchDraft?: { text: string; createdAt: number } + } + const clearArgs = (): { clearInput?: string } => + (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendWithOutcome.mockReset() + sendWithOutcome.mockResolvedValue('accepted') + clearInputWrite.mockReset() + clearInputWrite.mockResolvedValue(true) + }) + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + api = null + }) + + it('sizes the pre-clear to every line of a parked launch draft', async () => { + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('hello') + }) + expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT)) + }) + + it('issues the burst as its OWN write, before the body', async () => { + // Bundled into the body write it arrived as literal Ctrl+U text. + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('hello') + }) + expect(clearInputWrite).toHaveBeenCalledTimes(1) + expect(sendWithOutcome).toHaveBeenCalledTimes(1) + expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan( + sendWithOutcome.mock.invocationCallOrder[0]! + ) + }) + + it('aborts without sending the body when the clear is rejected', async () => { + // Sending on top of an uncleared line is exactly the concatenation bug. + clearInputWrite.mockResolvedValue(false) + mount(() => ({ text: DRAFT, createdAt: 1 })) + let result: boolean | undefined + await act(async () => { + result = await api!.send('hello') + }) + expect(result).toBe(false) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => { + // A Ctrl+U written immediately before body text in the SAME write arrives as + // a literal control character, so it would head the received message. + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('hello') + }) + expect(sentArgs().clearInputFirst).toBe(false) + expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) + }) + + it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => { + mount(() => null) + await act(async () => { + await api!.send('hello') + }) + expect(sentArgs().clearInputFirst).toBe(true) + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + }) + + it('writes no clear at all when nothing is parked on the line', async () => { + mount(() => null) + await act(async () => { + await api!.send('hello') + }) + expect(clearInputWrite).not.toHaveBeenCalled() + }) + + it('reads the draft at send time, so a retired seed stops widening the clear', async () => { + let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 } + mount(() => parked) + await act(async () => { + await api!.send('first') + }) + parked = null + await act(async () => { + await api!.send('second') + }) + expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true }) + expect(clearInputWrite).toHaveBeenCalledTimes(1) + expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false }) + }) + + it('does not clear an image send after the image was pasted', async () => { + // A second clear here would wipe the image that was just pasted. + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('caption', ['file:///a.png']) + }) + expect(clearInputWrite).not.toHaveBeenCalled() + expect(sentArgs().clearInputFirst).toBe(false) + expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) + }) + + it('does not resolve a composer seed from a question-card answer', async () => { + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.answerQuestion('1') + }) + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 366dd4265..4143af338 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -1,12 +1,15 @@ import { useCallback, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import { + clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' +import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' export type MobileNativeChatMessageSend = { /** Composer send that syncs the draft (clear on send, restore on rejection). */ @@ -32,6 +35,9 @@ export function useMobileNativeChatMessageSend(args: { handleRef: MutableRefObject deviceTokenRef: MutableRefObject captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + /** Launch-context text Orca parked on the agent's TUI input line, or null. Read + * at send time so the pre-clear can be sized to every line it occupies. */ + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void @@ -48,6 +54,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, deviceTokenRef, captureSendOrigin, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -95,6 +102,36 @@ export function useMobileNativeChatMessageSend(args: { if (syncComposer) { clearDraftForSend(origin, text) } + // Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears + // only one logical line. Size the clear to the text Orca injected, with + // slack — the user can also have typed into the TUI line directly, so that + // line count is a lower bound. Mobile cannot read the agent's screen, so + // there is no empty-line observable to confirm against here; the upper + // bound plus the host's write acceptance is what makes it safe. + // + // The burst goes out as its OWN write: bundled into the body write it + // arrived as literal Ctrl+U text and the draft concatenated (see + // clearMobileNativeChatInput). A rejected clear aborts the send rather + // than pasting on top of an uncleared line. + const seededLaunchDraft = readSeededLaunchDraftSeed() + if (seededLaunchDraft && !images?.length) { + const cleared = await clearMobileNativeChatInput({ + client, + terminal: handle, + clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), + deadline, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!cleared) { + if (syncComposer) { + restoreRejectedDraft(origin, text) + } + onSendError('Message not sent') + return 'rejected' + } + } const outcome = await sendMobileNativeChatMessageWithOutcome({ client, terminal: handle, @@ -105,7 +142,20 @@ export function useMobileNativeChatMessageSend(args: { // this message. An image send already led its own paste with Ctrl+U, and a // second one here would wipe the image it just pasted (desktop's image path // likewise clears once, before the paste, and never again). - clearInputFirst: !images?.length, + // + // Also skipped once the dedicated clear above ran: the line is already + // empty, and a Ctrl+U written immediately before body text in the SAME + // write reaches the agent as a literal control character rather than a + // keypress (observed live as a stray \x15 heading the received message). + clearInputFirst: !images?.length && !seededLaunchDraft, + ...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number' + ? { + resolvedLaunchDraft: { + text: seededLaunchDraft.text, + createdAt: seededLaunchDraft.createdAt + } + } + : {}), deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } @@ -141,6 +191,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, holdUnconfirmedSend, onSendError, + readSeededLaunchDraftSeed, restoreRejectedDraft ] ) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index ad4343ac8..9b5a010df 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -31,6 +31,9 @@ type Args = { images?: string[], deadline?: number ) => Promise + /** Launch-context text parked on the agent's TUI input line, or null — sizes + * the image paste's leading clear so a multi-line draft cannot ride along. */ + readonly readSeededLaunchDraft: () => string | null readonly showToast: (message: string, durationMs?: number) => void /** Native-chat send failures — rendered in the composer's inline banner. */ readonly onNativeChatSendError: (message: string) => void @@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, beforeTerminalSend, nativeChatBaseSend, + readSeededLaunchDraft, showToast, onNativeChatSendError, onSuccess, @@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({ showToast, onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, + readSeededLaunchDraft, onAttachSuccess: onSuccess, onError }) diff --git a/src/main/runtime/mobile-pairing-qr.test.ts b/src/main/runtime/mobile-pairing-qr.test.ts index c61366d81..3c860cbf6 100644 --- a/src/main/runtime/mobile-pairing-qr.test.ts +++ b/src/main/runtime/mobile-pairing-qr.test.ts @@ -3,6 +3,9 @@ import { encodePairingOffer } from '../../shared/pairing' import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer' import { encodeMobilePairingQr } from './mobile-pairing-qr' +// Keep every capacity probe in the same encoded payload family. +const FIXED_INVITE_EXPIRES_AT = Date.now() + 5 * 60_000 + function pairingUrl(endpointLength: number, relay: boolean): string { const prefix = 'wss://pair.example/' const offer: PairingOffer = { @@ -20,7 +23,7 @@ function pairingUrl(endpointLength: number, relay: boolean): string { assignmentEpoch: 1, relayHostId: 'a'.repeat(16), inviteToken: 'b'.repeat(43), - inviteExpiresAt: Date.now() + 60_000, + inviteExpiresAt: FIXED_INVITE_EXPIRES_AT, e2eeFraming: 2 } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3d9409512..2fd94df75 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -73,6 +73,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator' import { HEADLESS_RUNTIME_WINDOW_ID, type RuntimeMobileSessionTabsResult, + type RuntimeSyncWindowGraph, type RuntimeTerminalCreate } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' @@ -2514,6 +2515,130 @@ describe('OrcaRuntimeService', () => { }) }) + it('routes a launch-draft resolution to the handle-owning local and remote renderers', async () => { + const runtime = new OrcaRuntimeService(store) + const nativeChatLaunchDraftResolved = vi.fn() + const events: RuntimeClientEvent[] = [] + runtime.setNotifier({ nativeChatLaunchDraftResolved } as never) + runtime.onClientEvent((event) => events.push(event)) + runtime.attachWindow(1) + const graph: RuntimeSyncWindowGraph = { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ], + mobileSessionTabs: [ + { + worktree: 'repo-1::/tmp/worktree-a', + publicationEpoch: 'launch-draft-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude', + launchDraft: 'seed', + launchDraftCreatedAt: 7, + isActive: true + } + ] + } + ] + } + runtime.syncWindowGraph(1, graph) + const listed = await runtime.listMobileSessionTabs('branch:feature/foo') + const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal') + if (!mobileTab?.terminal) { + throw new Error('expected mobile terminal handle') + } + expect(mobileTab).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 7 }) + + runtime.notifyNativeChatLaunchDraftResolved(mobileTab.terminal, { + text: 'seed', + createdAt: 7 + }) + + expect(nativeChatLaunchDraftResolved).toHaveBeenCalledWith('tab-1', { + text: 'seed', + createdAt: 7 + }) + expect(events).toContainEqual({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) + expect(runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot()).toContainEqual({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) + const retired = (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + expect(retired).not.toHaveProperty('launchDraft') + expect(retired).not.toHaveProperty('launchDraftCreatedAt') + + runtime.markRendererReloading(1) + const replay = runtime.syncWindowGraph(1, { + ...graph, + mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({ + ...snapshot, + publicationEpoch: 'launch-draft-reload', + snapshotVersion: 2 + })) + }) + expect(replay.nativeChatLaunchDraftResolutions).toEqual([ + { tabId: 'tab-1', text: 'seed', createdAt: 7 } + ]) + expect( + (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + ).not.toHaveProperty('launchDraft') + + const reconciled = runtime.syncWindowGraph(1, { + ...graph, + mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({ + ...snapshot, + publicationEpoch: 'launch-draft-reload', + snapshotVersion: 3, + tabs: snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + return { ...tab, launchDraftCreatedAt: 8 } + }) + })) + }) + expect(reconciled.nativeChatLaunchDraftResolutions).toBeUndefined() + expect( + (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + ).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 8 }) + }) + it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => { const runtime = new OrcaRuntimeService(store) const tabId = 'tab-1' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4770f89e2..b972e482c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -324,6 +324,7 @@ import { type RuntimeMobileSessionTabsRemovedResult, type RuntimeMobileSessionTabsResult, type RuntimeMobileSessionTabsSnapshot, + type RuntimeNativeChatLaunchDraftResolution, type RuntimeSessionTabCloseReason, type RuntimeBrowserDriverState, type RuntimeTerminalDriverState, @@ -1789,6 +1790,10 @@ type RuntimeNotifier = { // and so a future write coordinator can use the same signal as scheduling // input. See docs/mobile-presence-lock.md. terminalDriverChanged(ptyId: string, driver: DriverState): void + nativeChatLaunchDraftResolved?( + tabId: string, + resolution: { text: string; createdAt: number } + ): void browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void } @@ -2594,6 +2599,12 @@ type LayoutQueueEntry = { }[] } +type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & { + worktreeId: string +} + +const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200 + async function hasLocalWorktreeBaseRef( repoPath: string, baseRef: string, @@ -2714,6 +2725,10 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + private nativeChatLaunchDraftResolutionByTabId = new Map< + string, + NativeChatLaunchDraftResolutionTombstone + >() private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null @@ -4717,12 +4732,146 @@ export class OrcaRuntimeService { return events } + getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } + >[] { + return [...this.nativeChatLaunchDraftResolutionByTabId.values()] + .sort((a, b) => a.tabId.localeCompare(b.tabId)) + .map(({ tabId, text, createdAt }) => ({ + type: 'nativeChatLaunchDraftResolved', + tabId, + text, + createdAt + })) + } + private emitClientEvent(event: RuntimeClientEvent): void { // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event') } + notifyNativeChatLaunchDraftResolved( + handle: string, + resolution: { text: string; createdAt: number } + ): void { + const owner = this.resolveNativeChatLaunchDraftOwner(handle) + if (!owner) { + return + } + const tombstone = { ...owner, ...resolution } + this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId) + this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone) + while ( + this.nativeChatLaunchDraftResolutionByTabId.size > + MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES + ) { + const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value + if (typeof oldestTabId !== 'string') { + break + } + this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId) + } + this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone) + this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution) + this.emitClientEvent({ + type: 'nativeChatLaunchDraftResolved', + tabId: owner.tabId, + ...resolution + }) + } + + private resolveNativeChatLaunchDraftOwner( + handle: string + ): { tabId: string; worktreeId: string } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (!record.tabId.startsWith('pty:')) { + return { tabId: record.tabId, worktreeId: record.worktreeId } + } + const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null + const tabId = + pty?.tabId && !pty.tabId.startsWith('pty:') + ? pty.tabId + : parsePaneKey(pty?.paneKey ?? '')?.tabId + if (!pty || !tabId || tabId.startsWith('pty:')) { + return null + } + return { tabId, worktreeId: pty.worktreeId } + } + + private retireResolvedNativeChatLaunchDraftFromMobileSnapshot( + resolution: NativeChatLaunchDraftResolutionTombstone + ): void { + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) { + continue + } + const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + if (next === snapshot) { + return + } + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...next, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + return + } + } + + private applyNativeChatLaunchDraftResolutionFence( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId) + if ( + !resolution || + !runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) || + tab.launchDraft !== resolution.text || + tab.launchDraftCreatedAt !== resolution.createdAt + ) { + return tab + } + changed = true + const next = { ...tab } + delete next.launchDraft + delete next.launchDraftCreatedAt + return next + }) + return changed ? { ...snapshot, tabs } : snapshot + } + + private reconcileNativeChatLaunchDraftResolutionTombstones( + snapshot: RuntimeMobileSessionTabsSnapshot + ): void { + for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) { + if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) { + continue + } + const surfaces = snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && tab.parentTabId === tabId + ) + if ( + surfaces.length === 0 || + !surfaces.some( + (tab) => + tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt + ) + ) { + this.nativeChatLaunchDraftResolutionByTabId.delete(tabId) + } + } + } + private notifyWorktreesChanged(repoId: string): void { this.notifier?.worktreesChanged(repoId) this.emitClientEvent({ type: 'worktreesChanged', repoId }) @@ -5186,9 +5335,14 @@ export class OrcaRuntimeService { } const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() + const nativeChatLaunchDraftResolutions = + this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map( + ({ tabId, text, createdAt }) => ({ tabId, text, createdAt }) + ) return { ...this.getStatus(), - ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}) + ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}) } } @@ -27890,7 +28044,9 @@ export class OrcaRuntimeService { ) { continue } - const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot) + this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) + const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than @@ -28505,6 +28661,9 @@ export class OrcaRuntimeService { ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), + ...(tab.launchDraftCreatedAt !== undefined + ? { launchDraftCreatedAt: tab.launchDraftCreatedAt } + : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 98c295c25..a4c103847 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -332,6 +332,14 @@ describe('remote runtime request connection integration', () => { const worktreeId = 'repo-1::C:\\repo\\feature' const ptyId = `${worktreeId}@@pty-1` let sleepSnapshot: RuntimeClientEvent[] = [] + const launchDraftResolutionSnapshot: RuntimeClientEvent[] = [ + { + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + } + ] const emit = (event: RuntimeClientEvent): void => { for (const listener of clientEventListeners) { listener(event) @@ -362,6 +370,7 @@ describe('remote runtime request connection integration', () => { return () => clientEventListeners.delete(listener) }, getTerminalSleepClientEventSnapshot: () => sleepSnapshot, + getNativeChatLaunchDraftResolutionClientEventSnapshot: () => launchDraftResolutionSnapshot, sleepTerminalsForWorktree: async () => { emit({ type: 'worktreeTerminalSleepState', @@ -440,6 +449,9 @@ describe('remote runtime request connection integration', () => { await waitFor(() => clientEvents.every((events) => events.some((e) => e.type === 'ready')) ) + for (const events of clientEvents) { + expect(events).toContainEqual(launchDraftResolutionSnapshot[0]) + } await expect( requester.request( 'terminal.sleep', @@ -487,6 +499,7 @@ describe('remote runtime request connection integration', () => { .filter((event) => event.type === 'worktreeTerminalSleepState') .map((event) => event.phase) ).toEqual(['committed']) + expect(reconnectedEvents).toContainEqual(launchDraftResolutionSnapshot[0]) sleepSnapshot = [] emit({ diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 9a7394825..956594dd5 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -38,6 +38,10 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) { emit(event) } + for (const event of runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot?.() ?? + []) { + emit(event) + } const sshStates = listRegisteredSshTargets().flatMap((target) => { const state = getPublicSshState(getRegisteredSshState(target.id) ?? null) return state ? [{ targetId: target.id, state }] : [] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 2f874ea9f..4ca479ea5 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -863,6 +863,12 @@ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), interrupt: z.unknown().optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), requireAgentStatus: z.enum(['sendable']).optional(), // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. inputKind: z.enum(['query-reply']).optional(), @@ -1183,6 +1189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalSend, handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -1361,6 +1368,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (result.accepted !== true) { mobileFloorClaim.current?.rollback() } + if ( + result.accepted === true && + params.enter === true && + params.client?.type === 'mobile' && + params.resolvedLaunchDraft + ) { + runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) + } // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. return { send: result } } diff --git a/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts new file mode 100644 index 000000000..9e5cf5238 --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const RESOLUTION = { text: 'seed', createdAt: 1 } + +function makeRequest(params: unknown): RpcRequest { + return { id: 'request', authToken: 'token', method: 'terminal.send', params } +} + +function makeRuntime(accepted: boolean): OrcaRuntimeService { + return { + getRuntimeId: () => 'runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + beginMobileInputFloor: vi.fn().mockReturnValue({ commit: vi.fn(), rollback: vi.fn() }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted, + bytesWritten: accepted ? 1 : 0 + }), + notifyNativeChatLaunchDraftResolved: vi.fn() + } as unknown as OrcaRuntimeService +} + +async function send( + runtime: OrcaRuntimeService, + options: { enter: boolean; clientType: 'mobile' | 'desktop' } +): Promise { + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + await dispatcher.dispatch( + makeRequest({ + terminal: 'terminal-1', + text: 'hello', + enter: options.enter, + resolvedLaunchDraft: RESOLUTION, + client: { id: 'client-1', type: options.clientType } + }) + ) +} + +describe('terminal.send launch-draft resolution', () => { + it('notifies after an accepted mobile submit', async () => { + const runtime = makeRuntime(true) + + await send(runtime, { enter: true, clientType: 'mobile' }) + + expect(runtime.notifyNativeChatLaunchDraftResolved).toHaveBeenCalledWith( + 'terminal-1', + RESOLUTION + ) + }) + + it.each([ + ['rejected submit', false, true, 'mobile'], + ['clear-only write', true, false, 'mobile'], + ['desktop submit', true, true, 'desktop'] + ] as const)('does not notify after a %s', async (_case, accepted, enter, clientType) => { + const runtime = makeRuntime(accepted) + + await send(runtime, { enter, clientType }) + + expect(runtime.notifyNativeChatLaunchDraftResolved).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 876339525..ba51231c0 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -473,6 +473,8 @@ function registerRuntimeWindowLifecycle( send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => send('runtime:terminalDriverChanged', { ptyId, driver }), + nativeChatLaunchDraftResolved: (tabId, resolution) => + send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }), browserDriverChanged: (browserPageId, driver) => send('runtime:browserDriverChanged', { browserPageId, driver }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3f5171108..f52d07fd7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3307,6 +3307,9 @@ export type PreloadApi = { onTerminalDriverChanged: ( callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void ) => () => void + onNativeChatLaunchDraftResolved?: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ) => () => void onBrowserDriverChanged: ( callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 3af7a2f01..fa42ae400 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4254,6 +4254,16 @@ const api = { ipcRenderer.on('runtime:terminalDriverChanged', listener) return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) }, + onNativeChatLaunchDraftResolved: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; text: string; createdAt: number } + ) => callback(data) + ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) + return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) + }, onBrowserDriverChanged: ( callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void ): (() => void) => { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d58cc2b40..6e7488059 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -8,6 +8,7 @@ import { submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { @@ -250,21 +251,29 @@ export const NativeChatComposer = forwardRef readTerminalScreen?.() + }) let pendingHandle: NativeChatSendHandle | null = null // Why: image attachments take the attachment send path even for a // command/unknown send, otherwise `clearImageAttachments()` below drops // them silently when the text starts with the agent's slash/skill prefix. if (classification !== 'chat' && imagePaths.length === 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) } else if (imagePaths.length > 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( target.settings, target.ptyId, text, - imagePaths + imagePaths, + sendOptions ) } else if (text.trim().length > 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) } else { submitNativeChatPrompt(target.settings, target.ptyId) } @@ -296,8 +305,7 @@ export const NativeChatComposer = forwardRef[0]> = {}) => + planNativeChatLaunchDraftSend({ + seededText: SEEDED, + ...over + }) + +describe('planNativeChatLaunchDraftSend', () => { + it('replaces the parked draft even when the composer copy is unchanged', () => { + expect(plan()).toEqual({ + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(SEEDED), + seededText: SEEDED + }) + }) + + it('does not submit a terminal-side edit that preserves the old short prefix', () => { + const samePrefixEdit = [ + '────────────────────────────────────────', + '❯ Linked Linear but terminal-side text changed', + '────────────────────────────────────────' + ].join('\n') + + const result = resolveNativeChatLaunchDraftSend({ + launchDraft: { agent: 'codex', text: SEEDED }, + launchDraftResolved: false, + agent: 'codex', + readScreen: () => samePrefixEdit + }) + expect(result.plan.kind).toBe('replace-draft') + }) + + it('keeps the ordinary send path when nothing is parked on the line', () => { + expect(plan({ seededText: null })).toEqual({ kind: 'default' }) + expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' }) + }) + + it('sizes a multi-line clear well past a single Ctrl+U', () => { + const result = plan() + expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan( + AGENT_TUI_CLEAR_INPUT_LINE.length + ) + }) +}) + +describe('agentInputLineCleared', () => { + it('confirms only an observably empty prompt', () => { + expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true) + }) + + it('does not call a different nonempty prompt cleared', () => { + const edited = [ + '────────────────────────────────────────', + '❯ issue: ABC-123 residue after a cursor-middle clear', + '────────────────────────────────────────' + ].join('\n') + expect(agentInputLineCleared(edited)).toBe(false) + }) + + it('does not ignore nonempty continuation rows after an empty prompt row', () => { + const residue = [ + '────────────────────────────────────────', + '❯ ', + ' suffix after a cursor-middle clear', + '────────────────────────────────────────' + ].join('\n') + expect(agentInputLineCleared(residue)).toBe(false) + }) + + it('treats placeholders and parked drafts as unconfirmed', () => { + expect(agentInputLineCleared(screenPlaceholder)).toBe(false) + expect(agentInputLineCleared(screenHoldingDraft)).toBe(false) + }) + + it('treats an unreadable screen as unconfirmed', () => { + expect(agentInputLineCleared(null)).toBe(false) + expect(agentInputLineCleared('unparseable')).toBe(false) + }) + + it('reads an empty prompt through serializer ANSI', () => { + expect(agentInputLineCleared(`❯ `)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts new file mode 100644 index 000000000..481540b03 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts @@ -0,0 +1,99 @@ +// Choosing how a chat send lands when the agent's TUI input line still holds a +// launch-context draft that Orca itself injected. + +import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +export type NativeChatLaunchDraftSendPlan = + /** Input line holds a stale injected draft — replace it, clearing every line. */ + | { kind: 'replace-draft'; clearInput: string; seededText: string } + /** No injected draft is parked on the line; keep the ordinary send path. */ + | { kind: 'default' } + +/** Prompt glyphs both supported agent TUIs draw at the start of the input line. */ +const COMPOSER_PROMPT_LINE = /^\s*([❯›])\s?(.*)$/ +const CLAUDE_FRAME_LINE = /^\s*─{3,}\s*$/ +const CODEX_FOOTER_LINE = /^\s*\S.*\s[·•]\s.*$/ + +function composerContinuationIsEmpty(lines: string[], promptIndex: number, glyph: string): boolean { + for (let index = promptIndex + 1; index < lines.length; index += 1) { + const line = lines[index]! + if ( + (glyph === '❯' && CLAUDE_FRAME_LINE.test(line)) || + (glyph === '›' && CODEX_FOOTER_LINE.test(line)) + ) { + return true + } + if (line.trim() !== '') { + return false + } + } + return true +} + +/** + * Whether the rendered composer prompt is observably empty. Placeholder text, + * unrelated edits, and unreadable screens are all unconfirmed. + */ +export function agentInputLineCleared(screen: string | null | undefined): boolean { + if (!screen) { + return false + } + const lines = stripScrollbackAnsi(screen).split('\n') + for (let index = lines.length - 1; index >= 0; index -= 1) { + const match = COMPOSER_PROMPT_LINE.exec(lines[index]!) + if (match) { + return match[2]!.trim() === '' && composerContinuationIsEmpty(lines, index, match[1]!) + } + } + return false +} + +/** + * A serialized screen cannot prove the whole parked draft still matches: visual + * wrapping loses logical-line boundaries. Always replace from the composer copy. + */ +export function planNativeChatLaunchDraftSend(args: { + /** Text Orca injected into the TUI line, or null when nothing is parked there. */ + seededText: string | null | undefined +}): NativeChatLaunchDraftSendPlan { + const seededText = args.seededText + if (!seededText || seededText.trim() === '') { + return { kind: 'default' } + } + return { + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(seededText), + seededText + } +} + +/** What a composer send needs: which path to take, and the clear/confirm bytes + * the ordinary send paths should use when it is a draft replacement. */ +export function resolveNativeChatLaunchDraftSend(args: { + launchDraft: { agent: string; text: string } | null | undefined + launchDraftResolved: boolean + agent: string + readScreen: () => string | null | undefined +}): { + plan: NativeChatLaunchDraftSendPlan + sendOptions: { clearInput: string; confirmCleared: () => boolean } | undefined +} { + const { launchDraft, launchDraftResolved, agent, readScreen } = args + // A resolved draft was already submitted or cleared TUI-side, so nothing of + // ours is on the line any more — treating it as parked would clear or submit + // a buffer that no longer holds it. + const seededText = + launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null + const plan = planNativeChatLaunchDraftSend({ seededText }) + if (plan.kind !== 'replace-draft') { + return { plan, sendOptions: undefined } + } + return { + plan, + sendOptions: { + clearInput: plan.clearInput, + confirmCleared: () => agentInputLineCleared(readScreen()) + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts index 1ccaefc42..4ae0ce9fe 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts @@ -9,6 +9,7 @@ export type NativeChatPtySendQueueHandle = { cancel: () => void settleAfterMs: number + settled: Promise bodyStarted: () => boolean finished: () => boolean } @@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend( const timers: ReturnType[] = [] let release: (() => void) | null = null + const finishEntry = (): void => { + if (finished) { + return + } + finished = true + const resolve = release + release = null + resolve?.() + } + const delay = (ms: number, fn: () => void): void => { const timer = setTimeout(() => { if (!cancelled) { @@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend( timers.push(timer) } - const markFinished = (): void => { - finished = true - } - const markSubmitted = (): void => { submitted = true + finishEntry() } const execute = (): Promise => new Promise((resolve) => { release = resolve if (cancelled) { - markFinished() + release = null + finished = true resolve() return } @@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend( start({ isCancelled: () => cancelled, delay, markSubmitted }) if (durationMs <= 0) { markSubmitted() - markFinished() - resolve() - return } - // Why: always release after the declared duration so a cancel mid-flight - // cannot stall the per-pty queue forever. - const done = setTimeout(() => { - markFinished() - resolve() - }, durationMs) - timers.push(done) }) const runPromise = @@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend( const settleQueueEntry = (): void => { state.depth = Math.max(0, state.depth - 1) - markFinished() + finished = true dropHandle() // Why: drop the per-pty record once nothing is in flight so the map does not // accumulate one permanent entry per pty over a long, multi-pane session. @@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend( } } - state.tail = runPromise.then(settleQueueEntry, settleQueueEntry) + const settled = runPromise.then(settleQueueEntry, settleQueueEntry) + state.tail = settled const handle: NativeChatPtySendQueueHandle = { cancel: () => { @@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend( clearTimeout(timer) } const shouldClear = bodyStarted && !submitted - markFinished() // Why: refund only THIS sequence's charged window rather than collapsing // freeAt to now — later queued sends still hold the line, so a blanket // reset would understate the next enqueue's settle time and let a send // card drop while a queued Enter is still pending. state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs)) - release?.() - release = null + finishEntry() dropHandle() if (shouldClear) { options?.onCancelUnsubmitted?.() } }, settleAfterMs, + settled, bodyStarted: () => bodyStarted, finished: () => finished } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts new file mode 100644 index 000000000..32f4947c3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -0,0 +1,195 @@ +// Send-path behaviour when a launch-context draft is still parked on the agent's +// TUI input line: the multi-line clear and its confirmation step. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) +})) + +import { + NATIVE_CHAT_CLEAR_CONFIRM_MS, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS, + resetNativeChatPtySendQueuesForTests, + sendNativeChatMessage, + sendNativeChatMessageWithImageAttachments +} from './native-chat-runtime-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { + AGENT_TUI_CLEAR_INPUT_MAX, + buildAgentTuiClearInputForText +} from '../../../../shared/agent-tui-input-clear' + +const SETTINGS = {} as Parameters[0] +const PTY = 'pty-launch-draft' +const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + +const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string) + +beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInput.mockClear() + sendRuntimePtyInput.mockReturnValue(true) + resetNativeChatPtySendQueuesForTests() +}) +afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() +}) + +describe('sendNativeChatMessage with a parked multi-line draft', () => { + it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput }) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')]) + expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) + + it('still defaults to a single Ctrl+U when no draft is parked', () => { + sendNativeChatMessage(SETTINGS, PTY, 'plain') + expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) + + it('holds the body until the clear is confirmed, then submits after the gap', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput, + confirmCleared: () => true + }) + // Body must NOT ride out with the clear — the confirm happens in between. + expect(writes()).toEqual([clearInput]) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited')]) + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT]) + }) + + it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => { + vi.useRealTimers() + const writeTimes = new Map() + sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => { + writeTimes.set(bytes, performance.now()) + return true + }) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput: buildAgentTuiClearInputForText(DRAFT), + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'queued') + + const blockedUntil = + performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50 + while (performance.now() < blockedUntil) { + // Simulate a renderer long task delaying both nominal deadlines. + } + + await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), { + timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000 + }) + await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true)) + expect( + writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))! + ).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20) + expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan( + writes().indexOf(buildNativeChatPasteBytes('queued')) + ) + }) + + it('widens to a maximal burst when the draft is still observed on the line', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput, + confirmCleared: () => false + }) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS) + expect(writes()).toEqual([ + clearInput, + AGENT_TUI_CLEAR_INPUT_MAX, + buildNativeChatPasteBytes('edited') + ]) + }) + + it('re-clears before the body, never after it', () => { + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput: buildAgentTuiClearInputForText(DRAFT), + confirmCleared: () => false + }) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) + const order = writes() + expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan( + order.indexOf(buildNativeChatPasteBytes('edited')) + ) + }) + + it('charges the confirm gap to the handle so the send card outlives the Enter', () => { + const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', { + clearInput: '\x15', + confirmCleared: () => true + }) + expect(withConfirm.settleAfterMs).toBe( + NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_CLEAR_CONFIRM_MS + ) + }) + + it('submits before a queued send starts after clear confirmation', async () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'first', { + clearInput, + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'second') + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(writes()).toEqual([ + clearInput, + buildNativeChatPasteBytes('first'), + NATIVE_CHAT_SUBMIT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('second') + ]) + }) +}) + +describe('image sends with a parked multi-line draft', () => { + it('clears every draft line before pasting, so no line rides along with the image', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput + }) + expect(writes()[0]).toBe(clearInput) + }) + + it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput + }) + vi.advanceTimersByTime(10_000) + expect(writes().filter((write) => write === clearInput)).toHaveLength(1) + }) + + it('submits the image send before a queued message starts', async () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput, + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'second') + + await vi.advanceTimersByTimeAsync( + NATIVE_CHAT_CLEAR_CONFIRM_MS + + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + + NATIVE_CHAT_SUBMIT_DELAY_MS + ) + + expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan( + writes().indexOf(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index e688c2254..93a63765a 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => { expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) + it('does not let a canceled queued send stall the sends behind it', async () => { + sendNativeChatMessage(SETTINGS, PTY, 'first') + const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled') + sendNativeChatMessage(SETTINGS, PTY, 'third') + canceled.cancel() + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('first'), + NATIVE_CHAT_SUBMIT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('third') + ]) + }) + it('does not serialize sends across different PTYs', () => { sendNativeChatMessage(SETTINGS, 'pty-a', 'one') sendNativeChatMessage(SETTINGS, 'pty-b', 'two') diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 33e653d07..a2974474b 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -8,6 +8,7 @@ import { } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' +import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' import { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, @@ -34,20 +35,86 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 // start from an empty line so a prior cancelled paste cannot glue onto the next // prompt. Not used on verified option commands — model-switch confirmation // observes the PTY and Ctrl+U can miss confirmation markers. +// +// One Ctrl+U only ever clears ONE logical line. When the line may hold an +// injected multi-line launch draft, callers pass `clearInput` built by +// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured +// 2N-1 law and the sequences that do NOT work. export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' +/** Gap before re-reading the agent's input line to confirm a clear landed. */ +export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 + +export type NativeChatSendOptions = { + /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ + clearInput?: string + /** + * Observed check that the input line is now empty. + * Supplied only for launch-draft replacement; when it reports "not cleared" + * the send widens to a maximal burst before writing the body rather than + * pasting on top of residue. + */ + confirmCleared?: () => boolean +} + /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { cancel: () => void /** Time after which every scheduled write has fired and the handle can drop. */ settleAfterMs: number + /** Actual completion, which can outlive the nominal schedule if the renderer stalls. */ + settled?: Promise } type RuntimeSettings = ReturnType -function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +function clearUnsubmittedAgentInput( + settings: RuntimeSettings, + ptyId: string, + options?: NativeChatSendOptions +): void { + sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +} + +/** + * Run `writeBody` once the input line is clear. With no `confirmCleared` the + * clear is a plain in-order write on the same byte stream, so the TUI consumes + * it before the body and the body follows immediately. With one, we pause to + * actually look at the agent's input line, and widen to a maximal burst when the + * draft is still visible — the injected line count is only a lower bound on what + * the buffer holds, since the user can type into the TUI directly. + */ +function clearThenWrite( + settings: RuntimeSettings, + ptyId: string, + options: NativeChatSendOptions | undefined, + delay: (ms: number, fn: () => void) => void, + writeBody: () => void +): void { + clearUnsubmittedAgentInput(settings, ptyId, options) + const confirmCleared = options?.confirmCleared + if (!confirmCleared) { + writeBody() + return + } + delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { + let cleared = false + try { + cleared = confirmCleared() + } catch { + // An unreadable terminal is unconfirmed; the maximal clear remains safe. + } + if (!cleared) { + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + } + writeBody() + }) +} + +/** Extra time a send needs when it stops to confirm the clear before the body. */ +function clearConfirmDurationMs(options?: NativeChatSendOptions): number { + return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 } /** @@ -61,27 +128,31 @@ function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): v export function sendNativeChatMessage( settings: RuntimeSettings, ptyId: string, - text: string + text: string, + options?: NativeChatSendOptions ): NativeChatSendHandle { return enqueueNativeChatPtySend( ptyId, - NATIVE_CHAT_SUBMIT_DELAY_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), ({ isCancelled, delay, markSubmitted }) => { if (isCancelled()) { return } - clearUnsubmittedAgentInput(settings, ptyId) - if (isCancelled()) { - return - } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + // Schedule from the actual body write: an overdue clear-confirm callback + // must not collapse the required body-to-Enter gap after a renderer stall. + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } ) } @@ -146,16 +217,17 @@ export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, text: string, - imagePaths: readonly string[] + imagePaths: readonly string[], + options?: NativeChatSendOptions ): NativeChatSendHandle { if (imagePaths.length === 0) { - return sendNativeChatMessage(settings, ptyId, text) + return sendNativeChatMessage(settings, ptyId, text, options) } const trimmedText = text.trim() const durationMs = - trimmedText.length > 0 + (trimmedText.length > 0 ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) return enqueueNativeChatPtySend( ptyId, durationMs, @@ -163,30 +235,31 @@ export function sendNativeChatMessageWithImageAttachments( if (isCancelled()) { return } - clearUnsubmittedAgentInput(settings, ptyId) - if (isCancelled()) { - return - } - for (const imagePath of imagePaths) { - sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) - } - if (trimmedText.length > 0) { - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - }) - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + for (const imagePath of imagePaths) { + sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) + } + if (trimmedText.length > 0) { + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) markSubmitted() }) - return - } - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx index 94c6252db..2c0bfaf65 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx @@ -175,6 +175,17 @@ describe('useNativeChatLaunchDraftSignal', () => { expect(result.current.launchDraft).not.toBeNull() }) + it('resolves immediately from an accepted mobile submission', () => { + mocks.storeState.nativeChatLaunchDraftByTabId = { + 'tab-1': launchDraft({ adopted: true, resolved: true, createdAt: SEEDED_AT }) + } + + const { result } = renderSignal([], true) + + expect(result.current.launchDraftResolved).toBe(true) + expect(result.current.launchDraft?.resolved).toBe(true) + }) + it('ignores a draft seeded for another agent', () => { mocks.storeState.nativeChatLaunchDraftByTabId = { 'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts index aa4eb0a6d..f82d5ab12 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts @@ -51,9 +51,10 @@ export function useNativeChatLaunchDraftSignal(args: { const baseline = held?.baseline ?? null const launchDraftResolved = useMemo( () => - paneLaunchDraft && !transcriptLoading + paneLaunchDraft?.resolved === true || + (paneLaunchDraft && !transcriptLoading ? launchDraftResolvedByTranscript(paneLaunchDraft, messages, baseline) - : false, + : false), [paneLaunchDraft, messages, baseline, transcriptLoading] ) return { launchDraft: paneLaunchDraft, launchDraftResolved } diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx index 63525c1e3..a9bb8c3be 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -66,4 +66,30 @@ describe('useNativeChatSendLifecycle', () => { expect(settled.cancel).not.toHaveBeenCalled() expect(onPendingSendCanceled).not.toHaveBeenCalled() }) + + it('keeps a renderer-stalled send cancelable past its nominal schedule', async () => { + vi.useFakeTimers() + let resolveSettled!: () => void + const stalled = { + ...handle(640), + settled: new Promise((resolve) => { + resolveSettled = resolve + }) + } + const onPendingSendCanceled = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.trackPendingSend(stalled, 'pending-1')) + act(() => vi.advanceTimersByTime(stalled.settleAfterMs + 1_000)) + rerender({ targetPtyId: 'pty-2' }) + + expect(stalled.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + await act(async () => { + resolveSettled() + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts index 41e46148b..c325cb220 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle( const pendingSendHandlesRef = useRef( new Map< NativeChatSendHandle, - { cleanupTimer: ReturnType; pendingId?: string } + { cleanupTimer: ReturnType | null; pendingId?: string } >() ) const cancelPendingSends = useCallback(() => { for (const [handle, entry] of pendingSendHandlesRef.current) { const { cleanupTimer, pendingId } = entry - clearTimeout(cleanupTimer) + if (cleanupTimer !== null) { + clearTimeout(cleanupTimer) + } handle.cancel() if (pendingId) { onPendingSendCanceled?.(pendingId) @@ -29,13 +31,22 @@ export function useNativeChatSendLifecycle( pendingSendHandlesRef.current.clear() }, [onPendingSendCanceled]) const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { - const cleanupTimer = setTimeout(() => { + const entry = { + cleanupTimer: null as ReturnType | null, + ...(pendingId ? { pendingId } : {}) + } + pendingSendHandlesRef.current.set(handle, entry) + if (handle.settled) { + void handle.settled.then(() => { + if (pendingSendHandlesRef.current.get(handle) === entry) { + pendingSendHandlesRef.current.delete(handle) + } + }) + return + } + entry.cleanupTimer = setTimeout(() => { pendingSendHandlesRef.current.delete(handle) }, handle.settleAfterMs) - pendingSendHandlesRef.current.set(handle, { - cleanupTimer, - ...(pendingId ? { pendingId } : {}) - }) }, []) // Why: delayed Enter/image writes belong to the exact PTY target. A pane diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index b89aa08a0..4f129edf8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -756,7 +756,7 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { expect(seededDraftFor('tab-1')?.text).toBe(ISSUE_URL) }) - it('leaves a multi-line draft in the terminal only', async () => { + it('mirrors a multi-line draft into chat', async () => { await submitFolderWorkspaceCreate({ projectGroup: makeProjectGroup(), name: '', @@ -770,8 +770,6 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { onOpenChange: vi.fn() }) - // Terminal still gets it; the chat mirror is withheld until multi-line send - // is safe, and decideInitialAgentTabViewMode keeps this launch in terminal. expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith( expect.objectContaining({ startup: expect.objectContaining({ @@ -779,7 +777,7 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { }) }) ) - expect(seededDraftFor('tab-1')).toBeUndefined() + expect(seededDraftFor('tab-1')?.text).toBe(`Reproduce on Windows first\n\n${ISSUE_URL}`) }) it('does not mirror an unlinked note, which is submitted rather than drafted', async () => { @@ -832,9 +830,9 @@ describe('folder-workspace draft: seeded set == chat-opening set', () => { // view-mode gate, and both must agree with what the composer actually holds. it.each([ ['argv-prefill', 'claude' as const, '', true], - ['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', false], + ['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', true], ['startup-paste', 'codex' as const, '', true], - ['startup-paste multi-line', 'codex' as const, 'Reproduce on Windows first', false] + ['startup-paste multi-line', 'codex' as const, 'Reproduce on Windows first', true] ])('%s', async (_label, quickAgent, note, expectMirrored) => { await submitFolderWorkspaceCreate({ projectGroup: makeProjectGroup(), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 33377008c..915df3dc0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -88,6 +88,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' @@ -936,6 +937,10 @@ export function useIpcEvents(): void { }) return } + if (event.type === 'nativeChatLaunchDraftResolved') { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) + return + } if (event.type === 'reposChanged') { runtimeProjectRefreshScheduler.request(environmentId) return @@ -3475,6 +3480,18 @@ export function useIpcEvents(): void { }) ) + const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( + (event) => { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), { + type: 'nativeChatLaunchDraftResolved', + ...event + }) + } + ) + if (unsubscribeLaunchDraftResolution) { + unsubs.push(unsubscribeLaunchDraftResolution) + } + unsubs.push( window.api.runtime.onBrowserDriverChanged((event) => { if (isRuntimeEnvironmentActive()) { diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 8672c6819..4d93a29db 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -31,17 +31,19 @@ describe('seedNativeChatLaunchDraftForAgentTab', () => { vi.clearAllMocks() }) - it('rejects multi-line text at the helper, not just at the delivery caller', () => { - // Worktree-create and work-item launches seed through this helper directly - // (a Linear draft is always `Linked Linear issue: …\n\n`), so the - // Ctrl+U kill-to-start-of-LINE constraint has to live here. - seedNativeChatLaunchDraftForAgentTab({ + it('mirrors multi-line text — the majority of real drafts', () => { + // A Linear draft is always `Linked Linear issue: …\n\n`, so rejecting + // newlines made every Linear launch invisible in chat. Send now clears every + // parked line first, so there is nothing left to glue. + const text = 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + seedNativeChatLaunchDraftForAgentTab({ tabId: 'linear-tab', agent: 'codex', text }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ tabId: 'linear-tab', agent: 'codex', - text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + text, + createdAt: expect.any(Number) }) - - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) it('seeds single-line text', () => { @@ -138,19 +140,23 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) - it('does not seed a launch draft for multi-line content', async () => { - // The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a - // multi-line prefill (e.g. scraped session-fork context) would leave earlier - // lines behind to glue onto the next message. + it('seeds a launch draft for multi-line content', async () => { + // Note+URL launches join with a blank line, so this shape is common too. + const content = 'Forked from session\n\nhttps://example.test/context' await deliverLaunchPromptToAgentTab({ tabId: 'fork-tab', agent: 'codex', - content: 'Forked from session\n\nhttps://example.test/context', + content, submit: false, forcePaste: false }) - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'fork-tab', + agent: 'codex', + text: content, + createdAt: expect.any(Number) + }) }) it('does not seed a launch draft for submitted, unsupported, or empty content', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 518b23f34..97621ec1c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -308,7 +308,7 @@ describe('launchAgentInNewTab', () => { ) }) - it('keeps a multi-line draft out of chat entirely', async () => { + it('mirrors a multi-line draft into chat and opens the tab there', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -319,21 +319,22 @@ describe('launchAgentInNewTab', () => { } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = 'Reproduce first\n\nhttps://github.com/o/r/issues/12' launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1', - prompt: 'Reproduce first\n\nhttps://github.com/o/r/issues/12', + prompt, promptDelivery: 'draft' }) - // Unseedable and un-opened must move together: a chat view here would be - // an empty composer beside a filled TUI input. - expect(mockSeedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt }) + ) expect(mockCreateTab).toHaveBeenCalledWith( 'wt-1', undefined, undefined, - expect.not.objectContaining({ viewMode: 'chat' }) + expect.objectContaining({ viewMode: 'chat' }) ) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 218011215..52243fcc6 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -467,10 +467,9 @@ describe('launchWorkItemDirect', () => { expect(startup?.launchDraftText).toBe('https://github.com/acme/repo/issues/12') }) - it('withholds the chat-composer launch draft for a multi-line Linear draft launch', async () => { - // A Linear draft is always `Linked Linear issue: ENG-42\n\n`. The chat - // send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so seeding it - // would leave the first line parked to glue onto the next message. + it('seeds the chat-composer launch draft for a multi-line Linear draft launch', async () => { + // A Linear draft is always `Linked Linear issue: ENG-42\n\n`, so withholding + // multi-line drafts made every Linear launch invisible in the chat view. mocks.ensureDetectedAgents.mockResolvedValue(['claude']) const { launchWorkItemDirect } = await import('./launch-work-item-direct') @@ -490,7 +489,12 @@ describe('launchWorkItemDirect', () => { }) ).resolves.toBe(true) - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'claude', + text: 'Linked Linear issue: ENG-42\nhttps://linear.app/acme/issue/ENG-42/ship-linear-parity\n', + createdAt: expect.any(Number) + }) }) it('preserves explicit Linear paste content submit-after-ready behavior', async () => { diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 37c792970..5744c7684 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -104,7 +104,21 @@ describe('decideInitialAgentTabViewMode', () => { it.each([ ['multi-line', 'Reproduce first\n\nhttps://github.com/o/r/issues/12'], - ['trailing-newline', 'https://github.com/o/r/issues/12\n'], + ['trailing-newline', 'https://github.com/o/r/issues/12\n'] + ])('opens a %s draft in chat with its mirrored composer text', (_label, launchDraftText) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'claude', + promptDelivery: 'draft', + launchDraftText + }) + ).toBe('chat') + }) + + it.each([ + ['Unicode-line-separator', 'one\u2028two'], ['blank', ' '], ['absent', undefined] ])('keeps a %s draft in the terminal, where its text actually is', (_label, launchDraftText) => { diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts index 33dd6f9a3..5cdc89ca9 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts @@ -13,6 +13,10 @@ vi.mock('@/store', () => ({ import { seedNativeChatLaunchDraftForAgentTab } from './agent-launch-prompt-delivery' import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability' import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode' +import { AGENT_TUI_CLEAR_MAX_LINES } from '../../../shared/agent-tui-input-clear' + +const maxLineDraft = Array.from({ length: AGENT_TUI_CLEAR_MAX_LINES }, () => 'line').join('\n') +const overMaxLineDraft = `${maxLineDraft}\nline` /** * Every shape a launch draft takes today. `formatDraftContextBlock` appends a @@ -28,6 +32,8 @@ const DRAFT_TEXTS = [ 'ORC-123: Restore linked quick-create\nhttps://linear.app/o/issue/ORC-123', 'ORC-123 https://linear.app/o/issue/ORC-123\n', ' spaced but single line ', + maxLineDraft, + overMaxLineDraft, '', ' ', '\n' @@ -69,18 +75,21 @@ describe('launch draft mirrorability', () => { expect(opensInChat(text)).toBe(expected) }) - // The only test that pins the rule itself; the agreement tests above adapt on - // their own. Multi-line send work updates the predicate body and this test. - it('accepts single-line text and rejects any line separator', () => { + it('accepts CR/LF drafts and rejects unsupported Unicode line separators', () => { expect(canMirrorLaunchDraftToNativeChat('https://github.com/o/r/issues/12')).toBe(true) - expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(false) - expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(true) + expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(true) expect(canMirrorLaunchDraftToNativeChat('one\u2028two')).toBe(false) expect(canMirrorLaunchDraftToNativeChat('one\u2029two')).toBe(false) - expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(true) expect(canMirrorLaunchDraftToNativeChat(' ')).toBe(false) }) + it('rejects drafts beyond the bounded TUI-clear budget', () => { + expect(canMirrorLaunchDraftToNativeChat(maxLineDraft)).toBe(true) + expect(canMirrorLaunchDraftToNativeChat(overMaxLineDraft)).toBe(false) + }) + it('withholds the mirror from agents without a native-chat renderer', () => { // The view mode already returns undefined for these, so the sets still // agree — but only the seeding side enforces it. diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts index 4afd4e6c2..23e7395c8 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts @@ -1,3 +1,8 @@ +import { + AGENT_TUI_CLEAR_MAX_LINES, + countAgentTuiInputLines +} from '../../../shared/agent-tui-input-clear' + /** * Single source of truth for whether unsent launch context can be mirrored from * the agent's TUI input into the native-chat composer. @@ -7,12 +12,13 @@ * predicate, so a draft launch can never open in chat with a composer that * chat then refuses to fill. * - * Multi-line is rejected because the chat send pre-clears the TUI input with - * Ctrl+U (\x15) — kill-to-start-of-LINE, not of the whole buffer — so earlier - * lines of a multi-line mirror would survive and concatenate onto the message - * being sent. Relaxing that rule belongs here and nowhere else: teaching this - * predicate to accept multi-line flips seeding and view mode together. + * CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line + * separators and drafts beyond that budget remain terminal-only. */ export function canMirrorLaunchDraftToNativeChat(text: string): boolean { - return text.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(text) + return ( + text.trim().length > 0 && + !/[\u2028\u2029]/.test(text) && + countAgentTuiInputLines(text) <= AGENT_TUI_CLEAR_MAX_LINES + ) } diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts index bffbd1537..b279730c8 100644 --- a/src/renderer/src/lib/native-chat-launch-prompt.ts +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -20,4 +20,6 @@ export type NativeChatLaunchDraft = { createdAt: number /** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */ adopted?: boolean + /** Accepted mobile submission consumed the TUI-side copy. */ + resolved?: boolean } diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 15623116f..b6f111c4d 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -501,11 +501,10 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) - // A draft opens in chat only when the composer can actually show it; the - // multi-line case would otherwise be an empty composer beside a filled TUI. it.each([ ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}] + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])('opens a %s draft startup payload accordingly', (_label, draftPrompt, expectedViewMode) => { const store = createMockStore({ settings: { @@ -538,7 +537,8 @@ describe('ensureWorktreeHasInitialTerminal', () => { // nothing mirrored — an empty composer beside a filled TUI input. it.each([ ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}] + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])( 'gates a %s argv-prefill draft on launchDraftText alone', (_label, launchDraftText, expectedViewMode) => { @@ -599,7 +599,8 @@ describe('ensureWorktreeHasInitialTerminal', () => { it.each([ ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}] + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])( 'opens a %s draft startup default tab accordingly', (_label, draftPrompt, expectedViewMode) => { diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index a3280957c..43c48eef2 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -133,7 +133,7 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }]) seedAgentTabStateAfterWorktreeCreate({ - request: { ...request, launchDraftPrompt: 'note\rhttps://github.com/o/r/issues/12' }, + request: { ...request, launchDraftPrompt: 'note\u2028https://github.com/o/r/issues/12' }, worktreeId: 'wt-1', primaryTabId: 'agent-tab', startupTerminalTabId: 'agent-tab', diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 3640d3ae6..ad24a8250 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -735,7 +735,7 @@ describe('staged background worktree creation', () => { it.each([ ['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'chat'], - ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'terminal'] + ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'chat'] ] as const)('passes %s draft mode to backend startup', async (_label, agent, draft, viewMode) => { store.settings.experimentalNativeChat = true store.settings.openAgentTabsInChatByDefault = true diff --git a/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts new file mode 100644 index 000000000..205392eeb --- /dev/null +++ b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' + +describe('applyNativeChatLaunchDraftResolved', () => { + it('routes the exact generation to the store action', () => { + const resolveNativeChatLaunchDraft = vi.fn() + + applyNativeChatLaunchDraftResolved( + { resolveNativeChatLaunchDraft }, + { type: 'nativeChatLaunchDraftResolved', tabId: 'tab-1', text: 'seed', createdAt: 7 } + ) + + expect(resolveNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1', { + text: 'seed', + createdAt: 7 + }) + }) +}) diff --git a/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts new file mode 100644 index 000000000..8bee4656f --- /dev/null +++ b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts @@ -0,0 +1,19 @@ +import type { AppState } from '@/store' +import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' + +type LaunchDraftResolvedEvent = Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } +> + +type LaunchDraftResolutionState = Pick + +export function applyNativeChatLaunchDraftResolved( + state: LaunchDraftResolutionState, + event: LaunchDraftResolvedEvent +): void { + state.resolveNativeChatLaunchDraft(event.tabId, { + text: event.text, + createdAt: event.createdAt + }) +} diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index 4f61c56b6..983ec41ca 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -54,17 +54,32 @@ describe('subscribeRuntimeClientEvents', () => { batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] } } }) + capturedOnResponse({ + ok: true, + result: { + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + } + }) capturedOnResponse({ ok: false, error: { code: 'method_not_found', message: 'missing' } }) - expect(onEvent).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenCalledTimes(3) expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' }) expect(onEvent).toHaveBeenCalledWith({ type: 'terminalSideEffects', batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] } }) + expect(onEvent).toHaveBeenCalledWith({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) expect(onError).toHaveBeenCalledWith({ code: 'method_not_found', message: 'missing' }) subscription.unsubscribe() diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 2a41dbb1c..263116637 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -86,6 +86,7 @@ function isRuntimeClientEvent( return ( message.type === 'reposChanged' || message.type === 'worktreesChanged' || + message.type === 'nativeChatLaunchDraftResolved' || message.type === 'terminalSideEffects' || message.type === 'sshStateChanged' || message.type === 'linearLinkedIssueUpdated' || diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 8d3a149e5..0ca21521f 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -741,11 +741,40 @@ describe('buildMobileSessionTabSnapshots', () => { expect.objectContaining({ type: 'terminal', parentTabId: 'term-1', - launchDraft: 'https://github.com/o/r/issues/12' + launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 1 }) ]) }) + it('retracts a launch draft as soon as mobile resolves it', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'claude' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as unknown as AppState['terminalLayoutsByTabId'], + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude', + text: 'issue link', + createdAt: 1, + resolved: true + } + } + }) + + expect(buildMobileSessionTabSnapshots(state)[0]?.tabs[0]).not.toHaveProperty('launchDraft') + }) + it('withholds a launch draft seeded for a different agent than the tab runs', () => { // The seed is keyed by tab id, which survives an agent switch. Desktop's // consumer declines on mismatch; publishing anyway would prefill the new diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 33cb27ed7..3b256b2e8 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -40,6 +40,7 @@ import { } from '../components/tab-bar/group-tab-order' import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' type RegisteredTerminalTab = { tabId: string @@ -734,9 +735,16 @@ async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) - getStoreState()?.setRuntimeAgentOrchestrationByPaneKey?.( - result?.agentOrchestrationByPaneKey ?? {} - ) + const currentState = getStoreState() + currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) + for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { + if (currentState) { + applyNativeChatLaunchDraftResolved(currentState, { + type: 'nativeChatLaunchDraftResolved', + ...resolution + }) + } + } } catch (error) { console.error('[runtime] Failed to sync renderer graph:', error) } @@ -1371,8 +1379,12 @@ function buildMobileTerminalSurfaceTabs( // tab id, so an unmatched seed would prefill the new agent's chat with stale text. const seededLaunchDraft = state.nativeChatLaunchDraftByTabId?.[terminal.id] const launchDraftEntry = - seededLaunchDraft && seededLaunchDraft.agent === terminal.launchAgent ? seededLaunchDraft : null - const launchDraftText = launchDraftEntry?.text.trim() ? launchDraftEntry.text : null + seededLaunchDraft && + !seededLaunchDraft.resolved && + seededLaunchDraft.agent === terminal.launchAgent + ? seededLaunchDraft + : null + const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null const container = registered?.getContainer() const firstChild = container?.firstElementChild const liveLayoutRoot = serializePaneTree( @@ -1435,7 +1447,12 @@ function buildMobileTerminalSurfaceTabs( ...(terminal.launchAgent ? { launchAgent: terminal.launchAgent } : {}), // Launch context that exists only as an unsent TUI-input draft; mobile // prefills its chat composer from it (desktop keeps its own seed store). - ...(launchDraftText ? { launchDraft: launchDraftText } : {}), + ...(publishedLaunchDraft + ? { + launchDraft: publishedLaunchDraft.text, + launchDraftCreatedAt: publishedLaunchDraft.createdAt + } + : {}), parentLayout, isActive: isDesktopTabActive && leafId === activeLeafId } diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index 79b1fd416..08408c343 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -127,6 +127,21 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false) }) + it('resolves only the exact draft generation', () => { + const store = createTestStore() + const entry = draft(TAB1, 'same text') + store.getState().seedNativeChatLaunchDraft(entry) + + store.getState().resolveNativeChatLaunchDraft(TAB1, { text: entry.text, createdAt: 0 }) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBeUndefined() + + store.getState().resolveNativeChatLaunchDraft(TAB1, { + text: entry.text, + createdAt: entry.createdAt + }) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBe(true) + }) + it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => { const store = createTestStore() seedDrafts(store) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 09911ad8b..27fd094ba 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -569,6 +569,10 @@ export type TerminalSlice = { nativeChatLaunchDraftByTabId: Record seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void markNativeChatLaunchDraftAdopted: (tabId: string) => void + resolveNativeChatLaunchDraft: ( + tabId: string, + resolution: Pick + ) => void clearNativeChatLaunchDraft: (tabId: string) => void pendingStartupByTabId: Record< string, @@ -1127,6 +1131,26 @@ export const createTerminalSlice: StateCreator }) }, + resolveNativeChatLaunchDraft: (tabId, resolution) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if ( + !current || + current.resolved || + current.createdAt !== resolution.createdAt || + current.text !== resolution.text + ) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, resolved: true } + } + } + }) + }, + clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5a262aed6..2ae7a4d50 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1343,6 +1343,7 @@ function createRuntimeApi(): NonNullable['runtime']> { reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), onTerminalFitOverrideChanged: () => noopUnsubscribe, onTerminalDriverChanged: () => noopUnsubscribe, + onNativeChatLaunchDraftResolved: () => noopUnsubscribe, onBrowserDriverChanged: () => noopUnsubscribe } } diff --git a/src/shared/agent-tui-input-clear.test.ts b/src/shared/agent-tui-input-clear.test.ts new file mode 100644 index 000000000..67715ce2d --- /dev/null +++ b/src/shared/agent-tui-input-clear.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_TUI_CLEAR_INPUT_FORWARD, + AGENT_TUI_CLEAR_INPUT_LINE, + AGENT_TUI_CLEAR_INPUT_MAX, + AGENT_TUI_CLEAR_LINE_SLACK, + AGENT_TUI_CLEAR_MAX_LINES, + buildAgentTuiClearInput, + buildAgentTuiClearInputForText, + countAgentTuiInputLines +} from './agent-tui-input-clear' + +const countCtrlU = (bytes: string): number => + bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length +const countCtrlK = (bytes: string): number => + bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_FORWARD).length + +describe('buildAgentTuiClearInput', () => { + // The measured law: N kills + (N-1) joins. A constant here silently under-clears + // (5 clears 3 lines but leaves residue at 4), which is what glues onto the next + // message — so pin every small N, not just one. + it.each([ + [1, 1], + [2, 3], + [3, 5], + [4, 7], + [10, 19] + ])('clears %i logical lines with %i Ctrl+U', (lines, expected) => { + const clearInput = buildAgentTuiClearInput(lines) + expect(countCtrlU(clearInput)).toBe(expected) + expect(countCtrlK(clearInput)).toBe(expected) + }) + + it('clears before the cursor before clearing the suffix after it', () => { + expect(buildAgentTuiClearInput(4)).toBe( + AGENT_TUI_CLEAR_INPUT_LINE.repeat(7) + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(7) + ) + }) + + it('still clears one line for a zero or negative count', () => { + expect(countCtrlU(buildAgentTuiClearInput(0))).toBe(1) + expect(countCtrlU(buildAgentTuiClearInput(-5))).toBe(1) + }) + + it('caps the burst so a pathological draft cannot emit an unbounded write', () => { + expect(countCtrlU(buildAgentTuiClearInput(10_000))).toBe(2 * AGENT_TUI_CLEAR_MAX_LINES - 1) + expect(AGENT_TUI_CLEAR_INPUT_MAX).toBe(buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES)) + }) +}) + +describe('countAgentTuiInputLines', () => { + it.each([ + ['one line', 1], + ['a\nb', 2], + ['a\r\nb\r\nc', 3], + ['a\rb', 2], + ['trailing\n', 2] + ])('counts %j as %i logical lines', (text, expected) => { + expect(countAgentTuiInputLines(text)).toBe(expected) + }) + + it('ignores visual wrapping — only logical newlines cost a Ctrl+U', () => { + expect(countAgentTuiInputLines('x'.repeat(5_000))).toBe(1) + }) +}) + +describe('buildAgentTuiClearInputForText', () => { + it('sizes the burst from the text plus slack for TUI-side edits', () => { + // The injected text is a LOWER bound: the user can type into the TUI line too. + expect(countCtrlU(buildAgentTuiClearInputForText('a\nb'))).toBe( + 2 * (2 + AGENT_TUI_CLEAR_LINE_SLACK) - 1 + ) + }) + + it('clears strictly more than the draft needs, never less', () => { + const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123\n' + expect(countCtrlU(buildAgentTuiClearInputForText(draft))).toBeGreaterThan( + 2 * countAgentTuiInputLines(draft) - 1 + ) + }) + + it('a long wrapped single line does not inflate the burst', () => { + expect(buildAgentTuiClearInputForText('y'.repeat(5_000))).toBe( + buildAgentTuiClearInputForText('y') + ) + }) +}) diff --git a/src/shared/agent-tui-input-clear.ts b/src/shared/agent-tui-input-clear.ts new file mode 100644 index 000000000..6d3bfc88e --- /dev/null +++ b/src/shared/agent-tui-input-clear.ts @@ -0,0 +1,45 @@ +// Clearing an agent TUI's input buffer when it may hold MORE THAN ONE line. +// +// Shared by desktop native chat and mobile: the law below is a property of the +// agent TUIs (Claude Code, codex), not of either client. + +/** Ctrl+U — clears toward the start of the input buffer. */ +export const AGENT_TUI_CLEAR_INPUT_LINE = '\x15' + +/** Ctrl+K — clears toward the end of the input buffer. */ +export const AGENT_TUI_CLEAR_INPUT_FORWARD = '\x0b' + +/** Clear up to `lineCount` logical lines from any cursor position. */ +export function buildAgentTuiClearInput(lineCount: number): string { + const lines = Math.max(1, Math.min(AGENT_TUI_CLEAR_MAX_LINES, Math.floor(lineCount))) + const repetitions = 2 * lines - 1 + return ( + AGENT_TUI_CLEAR_INPUT_LINE.repeat(repetitions) + + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(repetitions) + ) +} + +/** + * Headroom over the line count Orca knows about. The text Orca injected is a + * LOWER BOUND on what the buffer holds — the user can also type straight into + * the TUI line — so the count is deliberately biased upward. Overshoot is free: + * 41 Ctrl+U against a 1-line buffer measured perfectly clean on both agents, + * and an undershoot is what leaves residue to glue onto the next message. + */ +export const AGENT_TUI_CLEAR_LINE_SLACK = 8 + +/** Bounds the burst so a pathological draft cannot emit an unbounded write. */ +export const AGENT_TUI_CLEAR_MAX_LINES = 40 + +/** Widest burst we ever send — the remedy when a clear is not observed to land. */ +export const AGENT_TUI_CLEAR_INPUT_MAX = buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES) + +/** Logical lines in `text`. Visual wrapping is irrelevant to the clear cost. */ +export function countAgentTuiInputLines(text: string): number { + return text.split(/\r\n|\r|\n/).length +} + +/** Clear bytes for a buffer believed to hold `text`, with slack for TUI-side edits. */ +export function buildAgentTuiClearInputForText(text: string): string { + return buildAgentTuiClearInput(countAgentTuiInputLines(text) + AGENT_TUI_CLEAR_LINE_SLACK) +} diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 3e0b1a255..242dd44af 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -6,10 +6,12 @@ import type { } from './types' import type { SshConnectionState } from './ssh-types' import type { TerminalSideEffectBatch } from './terminal-side-effect-facts' +import type { RuntimeNativeChatLaunchDraftResolution } from './runtime-types' export type RuntimeClientEvent = | { type: 'reposChanged' } | { type: 'worktreesChanged'; repoId: string } + | ({ type: 'nativeChatLaunchDraftResolved' } & RuntimeNativeChatLaunchDraftResolution) | { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch } // Why: SSH connections live on the runtime host; paired clients have no IPC // channel for ssh:state-changed, so without this event their reconnect diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index fc55a6985..5eca1063a 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -146,10 +146,17 @@ export type RuntimeSyncWindowGraph = { mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[] } +export type RuntimeNativeChatLaunchDraftResolution = { + tabId: string + text: string + createdAt: number +} + export type RuntimeSyncWindowGraphResult = RuntimeStatus & { /** Main owns terminal handles/dispatches, so renderer graph sync returns the * parent metadata needed by title-derived agent rows without name guessing. */ agentOrchestrationByPaneKey?: Record + nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[] } export type RuntimeMobileSessionTerminalTab = { @@ -174,6 +181,8 @@ export type RuntimeMobileSessionTerminalTab = { /** Launch context delivered only into the TUI input as an unsent draft; the * mobile chat composer adopts it so the context isn't invisible in chat. */ launchDraft?: string + /** Identity of the launch draft text, used to retire only the adopted generation. */ + launchDraftCreatedAt?: number isActive: boolean }