mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
co-authored by
Merge Sim
parent
5412276776
commit
b0253673c9
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user