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 }