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

This commit is contained in:
Brennan Benson
2026-07-29 16:27:58 -07:00
parent 78bcb5f01c
commit b5982baabb
10 changed files with 69 additions and 246 deletions
@@ -361,6 +361,7 @@ export function useMobileNativeChatImageAttachments({
enabled,
onError,
onSendError,
readSeededLaunchDraft,
scopeKey,
sleep
]
@@ -145,12 +145,13 @@ describe('useMobileNativeChatMessageSend', () => {
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
})
it('still skips the pre-clear for an image send, which pasted its own first', async () => {
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(() => DRAFT)
await act(async () => {
await api!.send('caption', ['file:///a.png'])
})
expect(clearInputWrite).not.toHaveBeenCalled()
expect(sentArgs().clearInputFirst).toBe(false)
})
})
@@ -113,7 +113,7 @@ export function useMobileNativeChatMessageSend(args: {
// clearMobileNativeChatInput). A rejected clear aborts the send rather
// than pasting on top of an uncleared line.
const seededLaunchDraft = readSeededLaunchDraft()
if (seededLaunchDraft) {
if (seededLaunchDraft && !images?.length) {
const cleared = await clearMobileNativeChatInput({
client,
terminal: handle,
@@ -5,7 +5,6 @@ import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import {
sendNativeChatMessage,
sendNativeChatMessageWithImageAttachments,
submitNativeChatDraftInPlace,
submitNativeChatPrompt
} from './native-chat-runtime-send'
import type { NativeChatSendHandle } from './native-chat-runtime-send'
@@ -252,26 +251,18 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
return
}
const classification = classifySend(text)
// Why: the agent's input line may still hold the launch-context draft Orca
// injected at launch. When it does, the buffer already holds this exact
// message, so the send is Enter alone — that cannot concatenate and makes
// a multi-line draft submit as one turn. Otherwise the line must be
// cleared line-by-line before the body (see agent-tui-input-clear.ts).
const { plan: launchDraftPlan, sendOptions } = resolveNativeChatLaunchDraftSend({
// A parked launch draft must be cleared line-by-line before the body.
const { sendOptions } = resolveNativeChatLaunchDraftSend({
launchDraft,
launchDraftResolved,
agent,
text,
hasImages: imagePaths.length > 0,
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 (launchDraftPlan.kind === 'submit-in-place') {
pendingHandle = submitNativeChatDraftInPlace(target.settings, target.ptyId)
} else if (classification !== 'chat' && imagePaths.length === 0) {
if (classification !== 'chat' && imagePaths.length === 0) {
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
} else if (imagePaths.length > 0) {
pendingHandle = sendNativeChatMessageWithImageAttachments(
@@ -314,9 +305,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
clearSkillOrigin()
clearImageAttachments()
setNotice(null)
// Why: the send either submitted the parked prefill in place or cleared the
// TUI input line before its body — either way it is gone, so 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,
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
agentInputLineClearedSeededDraft,
agentInputLineShowsSeededDraft,
planNativeChatLaunchDraftSend
agentInputLineCleared,
planNativeChatLaunchDraftSend,
resolveNativeChatLaunchDraftSend
} from './native-chat-launch-draft-send'
import {
AGENT_TUI_CLEAR_INPUT_LINE,
@@ -20,7 +20,7 @@ const screenHoldingDraft = [
'────────────────────────────────────────'
].join('\n')
const screenClear = [
const screenPlaceholder = [
' ▘▘ ▝▝ ~/repo',
'────────────────────────────────────────',
'❯ Try "create a util logging.py that..."',
@@ -30,15 +30,32 @@ const screenClear = [
const plan = (over: Partial<Parameters<typeof planNativeChatLaunchDraftSend>[0]> = {}) =>
planNativeChatLaunchDraftSend({
seededText: SEEDED,
text: SEEDED,
hasImages: false,
readScreen: () => screenHoldingDraft,
...over
})
describe('planNativeChatLaunchDraftSend', () => {
it('submits in place when the composer still holds exactly the injected draft', () => {
expect(plan()).toEqual({ kind: 'submit-in-place' })
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', () => {
@@ -46,111 +63,39 @@ describe('planNativeChatLaunchDraftSend', () => {
expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' })
})
it('replaces the draft when the user edited it, sizing the clear from the INJECTED text', () => {
// The buffer holds what was injected, not what the composer now shows — a
// burst sized from the (possibly one-line) edit would leave earlier lines.
const result = plan({ text: 'just one line now' })
expect(result).toEqual({
kind: 'replace-draft',
clearInput: buildAgentTuiClearInputForText(SEEDED),
seededText: SEEDED
})
})
it('never submits in place for an image send, but still clears every draft line', () => {
const result = plan({ hasImages: true })
expect(result).toEqual({
kind: 'replace-draft',
clearInput: buildAgentTuiClearInputForText(SEEDED),
seededText: SEEDED
})
})
// The hazard: the user can clear or retype the TUI line directly in terminal
// view while the composer copy lives on. Pressing Enter then submits the wrong
// buffer — or an empty one — and the message is silently lost.
it('falls back to replace-draft when the input line no longer shows the draft', () => {
expect(plan({ readScreen: () => screenClear }).kind).toBe('replace-draft')
})
it('falls back to replace-draft when the screen cannot be read at all', () => {
expect(plan({ readScreen: () => null }).kind).toBe('replace-draft')
expect(plan({ readScreen: () => undefined }).kind).toBe('replace-draft')
})
it('sizes a multi-line clear well past a single Ctrl+U', () => {
const result = plan({ text: 'edited' })
const result = plan()
expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan(
AGENT_TUI_CLEAR_INPUT_LINE.length
)
})
})
describe('agentInputLineShowsSeededDraft', () => {
it('sees the draft still parked on the input line', () => {
expect(agentInputLineShowsSeededDraft(screenHoldingDraft, SEEDED)).toBe(true)
describe('agentInputLineCleared', () => {
it('confirms only an observably empty prompt', () => {
expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true)
})
it('reports gone once the TUI redraws its placeholder', () => {
expect(agentInputLineShowsSeededDraft(screenClear, SEEDED)).toBe(false)
})
it('reads codex frames too', () => {
const codex = ['› Linked Linear issue: ABC-123', ' gpt-5.6 · ~/repo'].join('\n')
expect(agentInputLineShowsSeededDraft(codex, SEEDED)).toBe(true)
})
it('ignores the same text sitting ABOVE as an already-sent turn', () => {
// Otherwise a sent draft reads as residue forever and every send re-clears.
const afterSend = [
'> Linked Linear issue: ABC-123',
' https://linear.app/x/issue/ABC-123',
it('does not call a different nonempty prompt cleared', () => {
const edited = [
'────────────────────────────────────────',
'❯ Try "create a util logging.py that..."',
'❯ issue: ABC-123 residue after a cursor-middle clear',
'────────────────────────────────────────'
].join('\n')
expect(agentInputLineShowsSeededDraft(afterSend, SEEDED)).toBe(false)
expect(agentInputLineCleared(edited)).toBe(false)
})
it('still sees a draft whose first line WRAPPED across rows', () => {
// The serializer breaks a wrapped line with a newline. A fingerprint long
// enough to span that break would miss and report the draft gone — the
// unsafe direction, since the caller would then paste on top of it.
const wrapped = [
'────────────────────',
'❯ Linked Linear issu',
' e: ABC-123',
' https://linear.app',
'────────────────────'
].join('\n')
expect(agentInputLineShowsSeededDraft(wrapped, SEEDED)).toBe(true)
it('treats placeholders and parked drafts as unconfirmed', () => {
expect(agentInputLineCleared(screenPlaceholder)).toBe(false)
expect(agentInputLineCleared(screenHoldingDraft)).toBe(false)
})
it('returns null — not a verdict — when nothing can be observed', () => {
expect(agentInputLineShowsSeededDraft(null, SEEDED)).toBeNull()
expect(agentInputLineShowsSeededDraft('no prompt glyph anywhere', SEEDED)).toBeNull()
expect(agentInputLineShowsSeededDraft(screenHoldingDraft, ' ')).toBeNull()
it('treats an unreadable screen as unconfirmed', () => {
expect(agentInputLineCleared(null)).toBe(false)
expect(agentInputLineCleared('unparseable')).toBe(false)
})
it('matches through the ANSI the serializer leaves in the frame', () => {
const ansi = `❯ Linked Linear issue: ABC-123`
expect(agentInputLineShowsSeededDraft(ansi, SEEDED)).toBe(true)
})
})
describe('agentInputLineClearedSeededDraft', () => {
it('confirms cleared only on a positive observation that the draft is gone', () => {
expect(agentInputLineClearedSeededDraft(screenClear, SEEDED)).toBe(true)
})
it('treats an unreadable screen as NOT cleared so the caller re-clears', () => {
// Wrong in this direction costs one harmless extra burst; the other direction
// pastes on top of residue and concatenates.
expect(agentInputLineClearedSeededDraft(null, SEEDED)).toBe(false)
expect(agentInputLineClearedSeededDraft('unparseable', SEEDED)).toBe(false)
})
it('reports not-cleared while the draft is still on the line', () => {
expect(agentInputLineClearedSeededDraft(screenHoldingDraft, SEEDED)).toBe(false)
it('reads an empty prompt through serializer ANSI', () => {
expect(agentInputLineCleared(`❯ `)).toBe(true)
})
})
@@ -1,102 +1,48 @@
// Choosing how a chat send lands when the agent's TUI input line still holds a
// launch-context draft that Orca itself injected.
//
// The point of the plan: Orca INJECTED that text, so when the input line still
// holds exactly what was injected, the buffer already IS the message. The send
// is then just the submit key — no clear, no paste, nothing that can
// concatenate, and multi-line submits as one turn with no buffer surgery.
import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear'
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
export type NativeChatLaunchDraftSendPlan =
/** Input line already holds exactly this text — press Enter and nothing else. */
| { kind: 'submit-in-place' }
/** 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 COMPOSER_PROMPT_LINE = /^\s*[❯›]\s?(.*)$/
/**
* A short prefix of the draft's first line. Deliberately SHORT: the input line
* wraps long text, and a fingerprint spanning a wrap would go unmatched — so a
* long one would report "gone" for a draft that is still sitting there.
* Whether the rendered composer prompt is observably empty. Placeholder text,
* unrelated edits, and unreadable screens are all unconfirmed.
*/
const DRAFT_FINGERPRINT_LENGTH = 12
function seededDraftFingerprint(seededText: string): string {
const firstLine = seededText.split(/\r\n|\r|\n/).find((line) => line.trim() !== '') ?? ''
return firstLine.trim().slice(0, DRAFT_FINGERPRINT_LENGTH)
}
/**
* Whether the agent's rendered input line still shows the injected draft.
* `null` means it could not be observed at all (no screen, no locatable prompt
* line, no usable fingerprint) — callers must treat that as "unknown" and pick
* whichever branch is safe when wrong, never as evidence either way.
*
* Both TUIs redraw their placeholder once the buffer is genuinely empty, so a
* `false` here is a real observation rather than an assumption that a write landed.
*/
export function agentInputLineShowsSeededDraft(
screen: string | null | undefined,
seededText: string
): boolean | null {
export function agentInputLineCleared(screen: string | null | undefined): boolean {
if (!screen) {
return null
}
const fingerprint = seededDraftFingerprint(seededText)
if (fingerprint === '') {
return null
return false
}
const lines = stripScrollbackAnsi(screen).split('\n')
let promptIndex = -1
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (COMPOSER_PROMPT_LINE.test(lines[index]!)) {
promptIndex = index
break
const match = COMPOSER_PROMPT_LINE.exec(lines[index]!)
if (match) {
return match[1]!.trim() === ''
}
}
if (promptIndex < 0) {
return null
}
// Only the input line downward: the same text sitting in the transcript above
// is a sent turn, not residue, and must not be read as a still-parked draft.
return lines.slice(promptIndex).join('\n').includes(fingerprint)
return false
}
/**
* Submitting in place is only safe on POSITIVE evidence that the draft is still
* on the input line. The user may have cleared or edited that line directly in
* terminal view while the composer copy lived on, and pressing Enter on a buffer
* that no longer holds the draft would submit the wrong thing (or nothing at
* all) and silently lose the message. Anything short of "yes, it is still there"
* falls through to replace-draft, which is correct in every state.
* 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
text: string
hasImages: boolean
readScreen: () => string | null | undefined
}): NativeChatLaunchDraftSendPlan {
const seededText = args.seededText
if (!seededText || seededText.trim() === '') {
return { kind: 'default' }
}
// Images must be pasted, so they can never submit in place — but they still
// need the multi-line clear, since the image path's own single Ctrl+U would
// leave every earlier line of the draft to glue onto the message.
if (
!args.hasImages &&
args.text === seededText &&
agentInputLineShowsSeededDraft(args.readScreen(), seededText) === true
) {
return { kind: 'submit-in-place' }
}
return {
kind: 'replace-draft',
clearInput: buildAgentTuiClearInputForText(seededText),
@@ -104,26 +50,12 @@ export function planNativeChatLaunchDraftSend(args: {
}
}
/**
* Did the clear empty the input line? Only a positive observation that the draft
* is GONE counts; unknown stays uncleared so the caller widens to a maximal
* burst. A wasted burst is harmless (measured), a missed one concatenates.
*/
export function agentInputLineClearedSeededDraft(
screen: string | null | undefined,
seededText: string
): boolean {
return agentInputLineShowsSeededDraft(screen, seededText) === false
}
/** 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
text: string
hasImages: boolean
readScreen: () => string | null | undefined
}): {
plan: NativeChatLaunchDraftSendPlan
@@ -135,12 +67,7 @@ export function resolveNativeChatLaunchDraftSend(args: {
// a buffer that no longer holds it.
const seededText =
launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null
const plan = planNativeChatLaunchDraftSend({
seededText,
text: args.text,
hasImages: args.hasImages,
readScreen
})
const plan = planNativeChatLaunchDraftSend({ seededText })
if (plan.kind !== 'replace-draft') {
return { plan, sendOptions: undefined }
}
@@ -148,7 +75,7 @@ export function resolveNativeChatLaunchDraftSend(args: {
plan,
sendOptions: {
clearInput: plan.clearInput,
confirmCleared: () => agentInputLineClearedSeededDraft(readScreen(), plan.seededText)
confirmCleared: () => agentInputLineCleared(readScreen())
}
}
}
@@ -1,5 +1,5 @@
// Send-path behaviour when a launch-context draft is still parked on the agent's
// TUI input line: submit-in-place, the multi-line clear, and the confirm step.
// TUI input line: the multi-line clear and its confirmation step.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -16,8 +16,7 @@ import {
NATIVE_CHAT_SUBMIT_DELAY_MS,
resetNativeChatPtySendQueuesForTests,
sendNativeChatMessage,
sendNativeChatMessageWithImageAttachments,
submitNativeChatDraftInPlace
sendNativeChatMessageWithImageAttachments
} from './native-chat-runtime-send'
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
import {
@@ -42,20 +41,6 @@ afterEach(() => {
resetNativeChatPtySendQueuesForTests()
})
describe('submitNativeChatDraftInPlace', () => {
it('writes ONLY the submit key — no clear, no body', () => {
// The buffer already holds the message, so anything else would either wipe
// it or paste a second copy alongside it.
submitNativeChatDraftInPlace(SETTINGS, PTY)
expect(writes()).toEqual([NATIVE_CHAT_SUBMIT])
})
it('does not write the clear byte at all', () => {
submitNativeChatDraftInPlace(SETTINGS, PTY)
expect(writes()).not.toContain(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
})
})
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)
@@ -49,7 +49,7 @@ 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 free of the injected draft.
* 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.
@@ -109,27 +109,6 @@ function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
}
/**
* Submit what the agent's input line ALREADY holds — Enter and nothing else.
*
* Used when the composer still holds exactly the launch draft Orca injected, so
* the TUI buffer is already the message. No clear and no paste means nothing can
* concatenate and a multi-line draft submits as one turn for free (measured: a
* bare CR submits a multi-line buffer whole on both agents).
*/
export function submitNativeChatDraftInPlace(
settings: RuntimeSettings,
ptyId: string
): NativeChatSendHandle {
return enqueueNativeChatPtySend(ptyId, 0, ({ isCancelled, markSubmitted }) => {
if (isCancelled()) {
return
}
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
}
/**
* Chat message path:
* 1. clear any unsubmitted TUI line
@@ -33,9 +33,8 @@ describe('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. The send path no
// longer pastes on top of the prefill (it submits the buffer in place, or
// clears every line first), so there is nothing left to glue.
// 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 })
@@ -12,10 +12,7 @@ export function seedNativeChatLaunchDraftForAgentTab(args: {
agent: TuiAgent
text: string
}): void {
// Multi-line drafts mirror too: the chat send no longer pastes on top of the
// parked prefill. It submits the TUI buffer in place when the composer still
// holds exactly what was injected, and otherwise clears every line before its
// body (agent-tui-input-clear.ts), so earlier lines cannot glue onto the message.
// Multi-line drafts are safe because chat clears every parked line before send.
if (args.text.trim().length === 0 || !isNativeChatSupportedAgent(args.agent)) {
return
}