fix(native-chat): mirror multi-line launch drafts into the chat composer (#11253)

* fix(native-chat): mirror multi-line launch drafts into the chat composer

seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline,
so every Linear launch ("Linked Linear issue: X\n<url>") and any GitHub launch
with a typed note was invisible in chat. The rejection existed because the send
path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with
embedded newlines.

Orca injects the draft itself, so when the composer still holds exactly what was
injected the buffer already IS the message: the send becomes the submit key
alone — no clear, no paste, nothing that can concatenate, and multi-line submits
as one turn for free. Only the edited case needs real buffer replacement, and
that now clears every line and verifies against the agent's rendered input line
instead of firing blind.

Measured on real PTYs against Claude Code and codex (both agree exactly):
clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts
for the law, the sequences that do NOT work, and why an upper bound is safe.

* fix(native-chat): send the mobile clear burst as its own write

Live QA caught the bundled form failing: a multi-line burst prefixed onto the
body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters,
so the parked draft survived and the message arrived as
draft + 21x \x15 + body. Sending the burst as its own non-submitting write —
the shape the image paste has always used — clears as intended.

The body write's own single-Ctrl+U prefix is dropped once that dedicated clear
ran, for the same reason: a Ctrl+U immediately followed by body text in one
write lands as a literal control character and headed the received message.

Re-verified live end to end: received prompt is exactly the draft, one turn,
zero control characters.

* test(native-chat): invert the multi-line Linear launch-draft mirror expectation

The Linear work-item launch seeds `Linked Linear issue: ENG-42\n<url>\n`.
This test pinned the pre-relaxation rule (multi-line drafts withheld), which
the send path no longer needs now that it submits the TUI buffer in place or
clears every line first — so it asserted the exact behavior the fix removes.

Assert the seeded payload instead of absence, so the test fails if the mirror
regresses to single-line-only.

* fix(native-chat): preserve launch draft send contents

* fix(native-chat): preserve confirmed send queue ordering

* fix(native-chat): preserve send pacing after renderer stalls

* test(native-chat): align activation with multiline draft mirroring

* fix(native-chat): clear launch drafts from any cursor

* fix(native-chat): retire mobile-consumed launch drafts

* test(mobile): stabilize QR capacity boundary fixture
This commit is contained in:
Brennan Benson 2026-07-30 11:08:56 -07:00 committed by GitHub
parent 914da17e52
commit bbb3e7e5ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 2076 additions and 193 deletions

View File

@ -3675,6 +3675,7 @@ export default function SessionScreen() {
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
showToast,
onNativeChatSendError: nativeChatSendError.show,
onSuccess: triggerSelection,

View File

@ -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
}

View File

@ -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

View File

@ -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')
})
})

View File

@ -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<boolean> {
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()

View File

@ -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')
})
})

View File

@ -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<boolean> {
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<boolean> {
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
}
}

View File

@ -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', () => {

View File

@ -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)
)

View File

@ -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
})

View File

@ -73,6 +73,9 @@ export type MobileNativeChatController = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** 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
}
}

View File

@ -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'))

View File

@ -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<SetStateAction<string>>
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<string, string>())
// 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<SetStateAction<string>> = useCallback(
(value) => {
@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: {
setComposerText,
pending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,

View File

@ -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<HookArgs> & Pick<HookArgs, 'client'>): 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 {

View File

@ -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<MobileNativeChatSendOutcome>
/** 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
]

View File

@ -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<SetStateAction<Record<string, string>>>
}): {
/** 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<string, MobileNativeChatLaunchDraftSeed | null>()
)
// 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 }
}

View File

@ -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<typeof useMobileNativeChatMessageSend>
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()
})
})

View File

@ -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<string | null>
deviceTokenRef: MutableRefObject<string | null>
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
]
)

View File

@ -31,6 +31,9 @@ type Args = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** 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
})

View File

@ -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
}
}

View File

@ -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'

View File

@ -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 }

View File

@ -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({

View File

@ -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 }] : []

View File

@ -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 }
}

View File

@ -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<void> {
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()
})
})

View File

@ -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 })
})

View File

@ -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

View File

@ -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) => {

View File

@ -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<NativeChatComposerHandle, NativeCha
return
}
const classification = classifySend(text)
// A parked launch draft must be cleared line-by-line before the body.
const { sendOptions } = resolveNativeChatLaunchDraftSend({
launchDraft,
launchDraftResolved,
agent,
readScreen: () => 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<NativeChatComposerHandle, NativeCha
clearSkillOrigin()
clearImageAttachments()
setNotice(null)
// Why: the send path pre-clears the TUI input line, so any launch-draft
// prefill still parked there is gone — retire the composer seed with it.
// The send cleared the TUI input line before its body, so retire the seed.
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
}, [
agent,
@ -308,6 +316,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
imageAttachments,
disabled,
isDispatchingSessionOption,
launchDraft,
launchDraftResolved,
readTerminalScreen,
resolveTarget,
onOptimisticSend,
onSlashCommand,

View File

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
agentInputLineCleared,
planNativeChatLaunchDraftSend,
resolveNativeChatLaunchDraftSend
} from './native-chat-launch-draft-send'
import {
AGENT_TUI_CLEAR_INPUT_LINE,
buildAgentTuiClearInputForText
} from '../../../../shared/agent-tui-input-clear'
const SEEDED = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
/** Claude's frame: the input line and its continuation rows sit last. */
const screenHoldingDraft = [
' ▘▘ ▝▝ ~/repo',
'────────────────────────────────────────',
' Linked Linear issue: ABC-123',
' https://linear.app/x/issue/ABC-123',
'────────────────────────────────────────'
].join('\n')
const screenPlaceholder = [
' ▘▘ ▝▝ ~/repo',
'────────────────────────────────────────',
' Try "create a util logging.py that..."',
'────────────────────────────────────────'
].join('\n')
const plan = (over: Partial<Parameters<typeof planNativeChatLaunchDraftSend>[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)
})
})

View File

@ -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())
}
}
}

View File

@ -9,6 +9,7 @@
export type NativeChatPtySendQueueHandle = {
cancel: () => void
settleAfterMs: number
settled: Promise<void>
bodyStarted: () => boolean
finished: () => boolean
}
@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend(
const timers: ReturnType<typeof setTimeout>[] = []
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<void> =>
new Promise<void>((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
}

View File

@ -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<typeof sendNativeChatMessage>[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<string, number>()
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)
)
})
})

View File

@ -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')

View File

@ -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<void>
}
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
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)
}
)
}

View File

@ -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 })

View File

@ -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 }

View File

@ -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<void>((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()
})
})
})

View File

@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle(
const pendingSendHandlesRef = useRef(
new Map<
NativeChatSendHandle,
{ cleanupTimer: ReturnType<typeof setTimeout>; pendingId?: string }
{ cleanupTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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

View File

@ -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(),

View File

@ -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()) {

View File

@ -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<url>\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<url>\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 () => {

View File

@ -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' })
)
})

View File

@ -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<url>\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<url>\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 () => {

View File

@ -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) => {

View File

@ -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.

View File

@ -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
)
}

View File

@ -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
}

View File

@ -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) => {

View File

@ -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',

View File

@ -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

View File

@ -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
})
})
})

View File

@ -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<AppState, 'resolveNativeChatLaunchDraft'>
export function applyNativeChatLaunchDraftResolved(
state: LaunchDraftResolutionState,
event: LaunchDraftResolvedEvent
): void {
state.resolveNativeChatLaunchDraft(event.tabId, {
text: event.text,
createdAt: event.createdAt
})
}

View File

@ -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()

View File

@ -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' ||

View File

@ -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

View File

@ -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<void> {
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
}

View File

@ -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)

View File

@ -569,6 +569,10 @@ export type TerminalSlice = {
nativeChatLaunchDraftByTabId: Record<string, NativeChatLaunchDraft>
seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void
markNativeChatLaunchDraftAdopted: (tabId: string) => void
resolveNativeChatLaunchDraft: (
tabId: string,
resolution: Pick<NativeChatLaunchDraft, 'createdAt' | 'text'>
) => void
clearNativeChatLaunchDraft: (tabId: string) => void
pendingStartupByTabId: Record<
string,
@ -1127,6 +1131,26 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
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]) {

View File

@ -1343,6 +1343,7 @@ function createRuntimeApi(): NonNullable<Partial<PreloadApi>['runtime']> {
reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }),
onTerminalFitOverrideChanged: () => noopUnsubscribe,
onTerminalDriverChanged: () => noopUnsubscribe,
onNativeChatLaunchDraftResolved: () => noopUnsubscribe,
onBrowserDriverChanged: () => noopUnsubscribe
}
}

View File

@ -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')
)
})
})

View File

@ -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)
}

View File

@ -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

View File

@ -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<string, AgentStatusOrchestrationContext>
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
}