fix(mobile): separate image attachment paths from following prompt text (STA-4847) (#15690)

* fix(mobile): delimit image paste payloads

Keep the shared mobile image-paste payload attachment-only. Apply the canonical conditional separator only to the final native-chat image when non-whitespace prompt text follows, preserving byte-clean clipboard and enter:false terminal consumers.

* refactor(mobile): drop unrelated churn from the image separator fix

Keep the bugfix diff to the separator itself: restore the textDeadline
local under the comment that explains it, and revert the scopeKey comment
restyle and blank-line deletion.

* fix(mobile): separate the terminal-mode image attach path too (STA-4847)

The dock attach button wrote a bare bracketed paste, so the user's next
keystroke glued onto the path -- the ticket's exact `...pngadd`, reproduced
on device. Attach-then-type is the whole interaction here, so unlike native
chat there is no following text to test: always separate.

Desktop's twin of this button is terminal-drop-path-writer, which #15820
already routed through the shared helper. Terminal clipboard paste stays
bare on both platforms, tracked separately (desktop: STA-5258).

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-04 12:16:52 -07:00
committed by GitHub
co-authored by Merge Sim
parent 5412276776
commit b0253673c9
7 changed files with 54 additions and 13 deletions
@@ -50,7 +50,9 @@ describe('attachMobileImageToTerminal', () => {
const sendCall = client.calls.find((c) => c.method === 'terminal.send')
expect(sendCall?.params).toEqual({
terminal: 'term-1',
text: '\x1b[200~/tmp/orca-attach.png\x1b[201~',
// Trailing space: the user types on this same line next, so a bare
// `…\x1b[201~` would arrive as `…pngadd` (STA-4847).
text: '\x1b[200~/tmp/orca-attach.png\x1b[201~ ',
enter: false,
client: { id: 'device-9', type: 'mobile' }
})
@@ -1,4 +1,5 @@
import type { RpcClient } from '../transport/rpc-client'
import { separateImagePasteFromFollowingText } from '../../../src/shared/image-paste-following-text'
import {
buildMobileImagePastePayload,
saveMobileClipboardImageAsTempFile
@@ -47,7 +48,10 @@ export async function attachMobileImageToTerminal(
})
// Why: a generated image path is terminal image injection, so it's always
// bracketed (matching desktop paste) regardless of terminal mode.
const payload = buildMobileImagePastePayload(imagePath)
// Always separated: attach-then-type is the whole interaction here, so the user's
// next keystroke would otherwise glue onto the path (`…pngadd`). Unlike native
// chat there is no batch to look ahead in, and a trailing space is inert.
const payload = separateImagePasteFromFollowingText(buildMobileImagePastePayload(imagePath), true)
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
return false
}
@@ -38,7 +38,8 @@ describe('pasteMobileNativeChatImagePaths', () => {
client,
terminal: 'term-1',
deviceToken: 'device-9',
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'],
followedByText: true
})
expect(ok).toBe(true)
@@ -55,7 +56,7 @@ describe('pasteMobileNativeChatImagePaths', () => {
})
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~')
expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~')
expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~ ')
})
it('stops and reports failure as soon as a paste is rejected', async () => {
@@ -66,7 +67,8 @@ describe('pasteMobileNativeChatImagePaths', () => {
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: true
})
expect(ok).toBe(false)
@@ -94,7 +96,8 @@ describe('pasteMobileNativeChatImagePaths', () => {
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: true
})
expect(ok).toBe(false)
@@ -121,6 +124,7 @@ describe('clearing a parked multi-line launch draft before the image paste', ()
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png'],
followedByText: true,
clearInput
})
@@ -137,6 +141,7 @@ describe('clearing a parked multi-line launch draft before the image paste', ()
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: true,
clearInput
})
@@ -151,9 +156,27 @@ describe('clearing a parked multi-line launch draft before the image paste', ()
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png']
imagePaths: ['/tmp/a.png'],
followedByText: true
})
expect(client.calls[0]?.params.text).toBe('\x15')
})
it('keeps image writes byte-clean when no text or submit follows', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: false
})
expect(client.calls.slice(1).map((call) => call.params.text)).toEqual([
'\x1b[200~/tmp/a.png\x1b[201~',
'\x1b[200~/tmp/b.png\x1b[201~'
])
})
})
@@ -1,4 +1,5 @@
import type { RpcClient } from '../transport/rpc-client'
import { imagePasteWritesFollowedByText } from '../../../src/shared/image-paste-following-text'
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
import {
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS,
@@ -23,6 +24,7 @@ type PasteImagesArgs = {
readonly terminal: string
readonly deviceToken: string | null
readonly imagePaths: readonly string[]
readonly followedByText: boolean
/** 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
@@ -42,6 +44,7 @@ export async function pasteMobileNativeChatImagePaths({
terminal,
deviceToken,
imagePaths,
followedByText,
deadline: sharedDeadline,
clearInput
}: PasteImagesArgs): Promise<boolean> {
@@ -55,7 +58,7 @@ export async function pasteMobileNativeChatImagePaths({
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
for (const text of [
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
...imagePaths.map(buildMobileImagePastePayload)
...imagePasteWritesFollowedByText(imagePaths.map(buildMobileImagePastePayload), followedByText)
]) {
const remainingMs = deadline - Date.now()
// Why: the budget is the whole sequence's — starting a write it can't fund would
@@ -55,6 +55,7 @@ export async function healMobileNativeChatStaleInput(args: {
terminal: args.terminal,
deviceToken: args.deviceToken,
imagePaths: [],
followedByText: false,
...(args.deadline === undefined ? {} : { deadline: args.deadline })
})
} catch {
@@ -182,9 +182,12 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(sendCalls).toHaveLength(2)
expect(sendCalls[0]?.params).toMatchObject({ text: '\x15', enter: false })
expect(sendCalls[1]?.params).toMatchObject({
text: '\x1b[200~/tmp/a.png\x1b[201~',
text: '\x1b[200~/tmp/a.png\x1b[201~ ',
enter: false
})
const combined = String(sendCalls[1]?.params.text ?? '') + 'look at this'
expect(combined).toContain('.png\x1b[201~ look')
expect(combined).not.toContain('.png\x1b[201~look')
// Clear, then paste, then settle, then the text send — in that order.
expect(order).toEqual(['clear', 'paste', 'settle', 'text:look at this'])
// The local preview URI rides along so the sent bubble shows the photo.
@@ -267,7 +270,10 @@ describe('useMobileNativeChatImageAttachments', () => {
}
})
it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
it.each([
['empty', ''],
['whitespace-only', ' ']
])('routes an attachments-only send through baseSend with %s text', async (_label, text) => {
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
@@ -283,16 +289,17 @@ describe('useMobileNativeChatImageAttachments', () => {
})
let accepted = false
await act(async () => {
accepted = await hook!.sendNativeChat('')
accepted = await hook!.sendNativeChat(text)
})
expect(accepted).toBe(true)
// Empty text still goes through baseSend (which submits the bare Enter) so the
// Attachment-only text still goes through baseSend (which submits Enter) so the
// optimistic echo carries the preview URI.
expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'], expect.any(Number))
expect(baseSend).toHaveBeenCalledWith(text, ['file:///a.jpg'], expect.any(Number))
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
// Only the clear + image paste hit the wire here; baseSend owns the submit.
expect(sendCalls).toHaveLength(2)
expect(sendCalls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
expect(hook!.attachments).toEqual([])
})
@@ -240,6 +240,7 @@ export function useMobileNativeChatImageAttachments({
terminal: handle,
deviceToken: deviceTokenRef.current,
imagePaths: pendingImages.map((attachment) => attachment.path),
followedByText: text.trim().length > 0,
deadline,
...(seededLaunchDraft
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }