fix(mobile): stop the chat pre-clear from wiping a just-pasted image

The text write set clearInputFirst unconditionally. On the image path that
Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image,
so the agent receives the text alone while acceptSend still renders the
thumbnail on the sent bubble — silent image loss.

Desktop's image path clears exactly once, before the paste, and never again;
mobile now matches: pre-clear only when nothing was deliberately pasted first.
The image paste already leads with its own Ctrl+U, so a launch-draft prefill
parked on the input line still cannot glue onto the message.

Pinned at both levels: the controller test drives the real send hook and
asserts clearInputFirst per branch, and the send module asserts the wire text
carries no leading \x15. The image-attachments test injects its own baseSend,
so it structurally could not observe this.
This commit is contained in:
Brennan Benson
2026-07-27 16:56:00 -07:00
parent 35f98262eb
commit db58121b57
3 changed files with 49 additions and 4 deletions
@@ -194,6 +194,27 @@ describe('sendMobileNativeChatMessage', () => {
)
})
it('sends the text verbatim when clearInputFirst is not set', async () => {
// An image send pastes the image (behind its own leading Ctrl+U) before this
// text write; a clear byte here would kill the pasted image off the input line.
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'what is this',
clearInputFirst: false
})
const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string }
expect(sent.text).toBe('what is this')
expect(sent.text.startsWith('\x15')).toBe(false)
})
it('sends a single non-submitting Escape for prompt cancellation', async () => {
const client = clientWithResponse({
id: 'request',
@@ -213,6 +213,27 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('pre-clears the input line for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
// agent receives text alone while the echo bubble still shows the thumbnail.
sendWithOutcome.mockResolvedValue('accepted')
await act(async () => {
await controller!.handleNativeChatSend('answer')
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'answer', clearInputFirst: true })
)
await act(async () => {
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'look', clearInputFirst: false })
)
})
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
sendWithOutcome.mockResolvedValue('unknown')
let accepted = false
@@ -99,10 +99,13 @@ export function useMobileNativeChatMessageSend(args: {
client,
terminal: handle,
text,
// Why: desktop-parity pre-clear. The stale-input heal above only fires for
// terminals a mobile image paste marked, so a desktop launch-draft prefill
// sitting on the input line would otherwise glue onto this message.
clearInputFirst: true,
// Why: pre-clear only when nothing was deliberately pasted first. The heal
// above fires only for terminals a mobile image paste marked, so a desktop
// launch-draft prefill parked on the input line would otherwise glue onto
// 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,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }