From b5982baabbc9768998aaf6d9964cf202b677753a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:27:58 -0700 Subject: [PATCH] fix(native-chat): preserve launch draft send contents --- ...se-mobile-native-chat-image-attachments.ts | 1 + ...se-mobile-native-chat-message-send.test.ts | 3 +- .../use-mobile-native-chat-message-send.ts | 2 +- .../native-chat/NativeChatComposer.tsx | 19 +-- .../native-chat-launch-draft-send.test.ts | 139 ++++++------------ .../native-chat-launch-draft-send.ts | 99 ++----------- ...ive-chat-runtime-send-launch-draft.test.ts | 19 +-- .../native-chat/native-chat-runtime-send.ts | 23 +-- .../lib/agent-launch-prompt-delivery.test.ts | 5 +- .../src/lib/agent-launch-prompt-delivery.ts | 5 +- 10 files changed, 69 insertions(+), 246 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 7cd1f437b29..a692b11acf1 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -361,6 +361,7 @@ export function useMobileNativeChatImageAttachments({ enabled, onError, onSendError, + readSeededLaunchDraft, scopeKey, sleep ] diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index ab17860615c..0fb699ac317 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -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) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 97d2752329d..b8f50ac9c05 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 3e328f3784b..6e74880594b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -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 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[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) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts index 3a242cf3b13..ff8f73aecd1 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts @@ -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()) } } } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 91165164096..41b510e2b50 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -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) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a604ee6b21a..e1f5e5ca18d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -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 diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 142832d221e..4d93a29dbe3 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -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\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 }) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 8dfdc2604c8..7fd3e101118 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -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 }