fix(mobile): send iPhone library photos as JPEG named .jpg, not HEIC named .png (#36989)

* fix(mobile): save iPhone library photos as JPEG named by their bytes

The iOS photo picker handed back the asset's HEIC bytes as-is, and the host
named every pasted image .png, so agents read a HEIC file as a PNG and failed.
Ask the picker for the compatible representation (JPEG), and name pasted
images by their signature so the extension always matches the bytes.

* fix(mobile): keep unknown pasted bytes on the .png name

A .heic name falls outside the shared image MIME table, so chat previews
and agent prompts would drop the image. The picker now sends JPEG, so
leave HEIC detection out.

* refactor(mobile): keep the pasted image extension list module-private
This commit is contained in:
Kelvin Amoaba
2026-11-01 10:32:11 +00:00
committed by GitHub
parent 416a3a03f2
commit 4e2fcfe99c
12 changed files with 67 additions and 14 deletions
@@ -22,7 +22,7 @@ export function mobilePickedFileFallbackName(mime: string, index: number): strin
return extension ? `attachment-${index}.${extension}` : `attachment-${index}`
}
// Images keep the host's orca-paste-*.png name, which transcript readers recognize as a pasted image.
// Images take the host's orca-paste-* name, which transcript readers recognize as a pasted image.
export function isPickedImageMime(mime: string | null | undefined): boolean {
return mime?.trim().toLowerCase().startsWith('image/') ?? false
}
@@ -15,6 +15,7 @@ const expo = vi.hoisted(() => ({
vi.mock('expo-clipboard', () => ({ getImageAsync: expo.getImageAsync }))
vi.mock('expo-document-picker', () => ({ getDocumentAsync: expo.getDocumentAsync }))
vi.mock('expo-image-picker', () => ({
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
launchImageLibraryAsync: expo.launchImageLibraryAsync,
requestMediaLibraryPermissionsAsync: expo.requestMediaLibraryPermissionsAsync
}))
@@ -92,7 +93,9 @@ describe('how the pickers are launched', () => {
expect.objectContaining({
mediaTypes: ['images'],
base64: false,
allowsMultipleSelection: false
allowsMultipleSelection: false,
// Agents can't read the HEIC iOS hands back by default.
preferredAssetRepresentationMode: 'compatible'
})
)
// Omit selectionLimit for single picks; zero means unlimited.
+4 -1
View File
@@ -51,7 +51,10 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi
base64: false,
allowsMultipleSelection: multiple,
...(multiple ? { selectionLimit: limit, orderedSelection: true } : {}),
quality: 1
quality: 1,
// iOS otherwise hands back HEIC as-is, which agents can't read; Compatible gives JPEG.
preferredAssetRepresentationMode:
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible
}),
// DocumentPicker has no selection cap; enforce remaining room again before staging and minting.
launchFiles: ({ multiple }) =>
@@ -44,6 +44,7 @@ vi.mock('expo-keep-awake', () => ({
}
}))
vi.mock('expo-image-picker', () => ({
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
launchImageLibraryAsync: device.picker.launchImageLibraryAsync
}))
vi.mock('expo-file-system', () => ({
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
vi.mock('expo-image-picker', () => ({
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
requestMediaLibraryPermissionsAsync: vi.fn(),
launchImageLibraryAsync: vi.fn()
}))
@@ -78,7 +79,9 @@ describe('pickMobileImage', () => {
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///x.jpg'
})
expect(launchLibrary).toHaveBeenCalledWith(expect.objectContaining({ base64: false }))
expect(launchLibrary).toHaveBeenCalledWith(
expect.objectContaining({ base64: false, preferredAssetRepresentationMode: 'compatible' })
)
expect(fetchSpy).not.toHaveBeenCalled()
expect(file.close).toHaveBeenCalledTimes(1)
})
@@ -90,7 +90,10 @@ async function* pickFromLibrary(
base64: false,
allowsMultipleSelection: multiple,
...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}),
quality: 1
quality: 1,
// iOS otherwise hands back HEIC as-is, which agents can't read; Compatible gives JPEG.
preferredAssetRepresentationMode:
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible
})
if (result.canceled) {
return
@@ -6,6 +6,7 @@ import {
timestampMs
} from '../ai-vault/session-scanner-values'
import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks'
import { AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE } from '../../shared/agent-session-attachments'
export function decodeGrokTranscriptLine(
line: string,
@@ -209,11 +210,16 @@ function normalizeGrokUserQueryBlock(block: NativeChatBlock): NativeChatBlock[]
]
}
const GROK_PASTED_IMAGE_QUERY = new RegExp(
String.raw`^((?:[a-z]:[\\/]|\/|[\\/]{2}[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/])(?:.*?[\\/])?orca-paste-[^\\/\r\n]+?` +
AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE +
String.raw`)([\s\S]*)$`,
'i'
)
function splitGrokPastedImageQuery(text: string): { path: string; query: string } | null {
// Grok 0.2.93 concatenates clipboard temp paths with prompts; recover the attachment separately.
const match = text.match(
/^((?:[a-z]:[\\/]|\/|[\\/]{2}[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/])(?:.*?[\\/])?orca-paste-[^\\/\r\n]+?\.png)([\s\S]*)$/i
)
const match = text.match(GROK_PASTED_IMAGE_QUERY)
if (!match?.[1]) {
return null
}
@@ -36,6 +36,7 @@ describe('decodeGrokTranscriptLine', () => {
it.each([
'C:\\Users\\me\\AppData\\Local\\Temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
'/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
'/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.jpg',
'C:\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
'/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
'\\\\server\\temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png'
@@ -32,6 +32,13 @@ describe('saveClipboardImageBufferAsTempFile', () => {
expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3]))
})
it('names a JPEG phone pick by its bytes, not as a .png', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1])
const savedPath = await saveClipboardImageBufferAsTempFile(jpeg)
expect(basename(savedPath)).toMatch(/^orca-paste-\d+-uuid-1\.jpg$/)
})
it('writes a terminal-backed composer paste into the never-swept paste folder', async () => {
const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3]), {
forNativeChatDraft: true
+11 -2
View File
@@ -10,11 +10,12 @@ import {
sanitizeAgentSessionAttachmentName,
type AgentSessionAttachmentClipboardTarget
} from '../../shared/agent-session-attachments'
import { detectImageFileSignature } from '../image-file-signature'
import { nativeChatPasteFolder } from './native-chat-paste-files'
export type SaveClipboardImageAsTempFileArgs = {
connectionId?: string | null
/** A phone-picked file's own name; without it the paste is saved as a .png. */
/** A phone-picked file's own name; without it the paste is named by its image bytes. */
fileName?: string
runtimeEnvironmentId?: string | null
/** With `runtimeEnvironmentId`: store the image as an attachment of this structured chat. */
@@ -49,7 +50,15 @@ export async function saveClipboardImageBufferAsTempFile(
// A picked file keeps its own name, so the paste gets a folder to stay unique.
const tempPath = args?.fileName
? path.join(folder, `orca-paste-${randomUUID()}`, shellInertFileName(args.fileName))
: path.join(folder, agentSessionPastedImageName(Date.now(), randomUUID()))
: path.join(
folder,
// Phone library picks are often JPEG; agents trust the extension over the bytes.
agentSessionPastedImageName(
Date.now(),
randomUUID(),
detectImageFileSignature(buffer)?.extension
)
)
if (args?.fileName) {
await fs.mkdir(path.dirname(tempPath), { recursive: true })
}
+14 -3
View File
@@ -122,9 +122,20 @@ export type AgentSessionAttachmentClipboardTarget = Omit<
'environmentId'
>
/** `orca-paste-…png`, the name every pasted image gets, so it reads as "Pasted image". */
export function agentSessionPastedImageName(now: number, unique: string): string {
return `orca-paste-${now}-${unique}.png`
/** Extensions a pasted image's name can carry; the host picks one from the image's bytes. */
const AGENT_SESSION_PASTED_IMAGE_EXTENSIONS = ['png', 'jpg', 'gif', 'webp'] as const
type AgentSessionPastedImageExtension = (typeof AGENT_SESSION_PASTED_IMAGE_EXTENSIONS)[number]
/** Matches the `.png`, `.jpg`… suffix of a pasted image's name. */
export const AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE = `\\.(?:${AGENT_SESSION_PASTED_IMAGE_EXTENSIONS.join('|')})`
/** `orca-paste-…<ext>`, the name every pasted image gets, so it reads as "Pasted image". */
export function agentSessionPastedImageName(
now: number,
unique: string,
extension: AgentSessionPastedImageExtension = 'png'
): string {
return `orca-paste-${now}-${unique}.${extension}`
}
/** `orca-paste-…txt`, the name a long text paste saved as a file gets, so it reads as "Pasted text". */
+7 -1
View File
@@ -1,3 +1,4 @@
import { AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE } from './agent-session-attachments'
import { getRuntimePathBasename } from './cross-platform-path'
import type { NativeChatImageRefBlock } from './native-chat/native-chat-types'
@@ -11,10 +12,15 @@ export const NATIVE_CHAT_IMAGE_LABEL_ENGLISH: NativeChatImageLabelCopy = {
imageCount: (count) => `${count} images`
}
const PASTED_IMAGE_NAME = new RegExp(
`^orca-paste-.+${AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE}$`,
'i'
)
/** Use friendly UI labels for Orca clipboard-image temp filenames. */
export function isNativeChatPastedImagePath(path: string): boolean {
const base = path.split(/[\\/]/).findLast(Boolean) ?? path
return /^orca-paste-.+\.png$/i.test(base)
return PASTED_IMAGE_NAME.test(base)
}
/** What a chat image is called where it is not drawn: its alt text, else its file's name. */