diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 3066673a6..618545e3f 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -179,7 +179,7 @@ import {
} from '../../../../src/session/mobile-terminal-tab-agent'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
-import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
+import { useMobileSessionImageAttachments } from '../../../../src/session/use-mobile-session-image-attachments'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
@@ -213,6 +213,7 @@ import { useMobileNativeChatReadability } from '../../../../src/session/use-mobi
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
+import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
@@ -3627,14 +3628,20 @@ export default function SessionScreen() {
showToast
})
- const { attachImage, isAttaching } = useMobileImageAttachment({
+ // Terminal input pastes an attached image straight into the visible terminal;
+ // native chat instead holds it as a composer chip and rides it along on submit.
+ const { attachImage, isAttaching, nativeChatImages } = useMobileSessionImageAttachments({
client,
activeHandle,
+ activeHandleRef,
canSend,
connState,
deviceTokenRef,
- beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
+ nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
+ nativeChatInputLeaseReady,
getActiveWorktreeConnectionId,
+ beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
+ nativeChatBaseSend: nativeChatController.handleNativeChatSend,
showToast,
onSuccess: triggerSelection,
onError: triggerError
@@ -4384,6 +4391,7 @@ export default function SessionScreen() {
hostedChecksSupported: prIsGithubRepo
})
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
+ const createTabBusy = creating || creatingBrowser || creatingMarkdown
return (
@@ -4586,24 +4594,16 @@ export default function SessionScreen() {
{
setCreateError('')
setShowCreateTabDrawer(true)
}}
>
- {creating || creatingBrowser || creatingMarkdown
- ? 'Creating...'
- : 'Create Tab'}
+ {createTabBusy ? 'Creating...' : 'Create Tab'}
@@ -4720,8 +4720,7 @@ export default function SessionScreen() {
))}
void attachImage('library')}
- isAttaching={isAttaching}
+ images={nativeChatImages}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts
index ef2c10126..36006d2f6 100644
--- a/mobile/src/session/MobileNativeChatComposer.test.ts
+++ b/mobile/src/session/MobileNativeChatComposer.test.ts
@@ -7,6 +7,7 @@ vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
+ Image: 'Image',
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
@@ -24,7 +25,8 @@ vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ImagePlus: 'ImagePlus',
Mic: 'Mic',
- Square: 'Square'
+ Square: 'Square',
+ X: 'X'
}))
function suppressRendererWarning(): () => void {
@@ -112,6 +114,61 @@ describe('MobileNativeChatComposer', () => {
expect(onSend).not.toHaveBeenCalled()
})
+ it('renders a removable thumbnail for each pending image attachment', async () => {
+ const onRemoveAttachment = vi.fn()
+ const restore = suppressRendererWarning()
+ try {
+ await act(async () => {
+ renderer = create(
+ createElement(MobileNativeChatComposer, {
+ value: '',
+ onChangeText: vi.fn(),
+ onSend: vi.fn().mockResolvedValue(true),
+ attachments: [
+ { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
+ { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
+ ],
+ onRemoveAttachment
+ })
+ )
+ })
+ } finally {
+ restore()
+ }
+ const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
+ props: { source: { uri: string } }
+ }>
+ expect(thumbs.map((t) => t.props.source.uri)).toEqual(['file:///a.png', 'file:///b.png'])
+
+ const remove = renderer!.root.findAll(
+ (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Remove image'
+ ) as Array<{ props: { onPress: () => void } }>
+ remove[1].props.onPress()
+ expect(onRemoveAttachment).toHaveBeenCalledWith('img-2')
+ })
+
+ it('enables send with an attached image even when the text is empty', async () => {
+ const onSend = vi.fn().mockResolvedValue(true)
+ const restore = suppressRendererWarning()
+ try {
+ await act(async () => {
+ renderer = create(
+ createElement(MobileNativeChatComposer, {
+ value: '',
+ onChangeText: vi.fn(),
+ onSend,
+ attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
+ })
+ )
+ })
+ } finally {
+ restore()
+ }
+ expect(sendButton().props).toMatchObject({ disabled: false })
+ await act(async () => sendButton().props.onPress())
+ expect(onSend).toHaveBeenCalledWith('')
+ })
+
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx
index ba02fe3d4..58508be77 100644
--- a/mobile/src/session/MobileNativeChatComposer.tsx
+++ b/mobile/src/session/MobileNativeChatComposer.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
+ Image,
Pressable,
ScrollView,
StyleSheet,
@@ -8,13 +9,14 @@ import {
TextInput,
View
} from 'react-native'
-import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native'
+import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSuggestions
} from './mobile-native-chat-autocomplete'
+import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
// Common agent slash commands offered as autocomplete; sending them is just text
// to the agent's terminal, so the set is intentionally provider-agnostic.
@@ -30,6 +32,7 @@ const SLASH_COMMANDS = [
]
const NO_FILE_PATHS: string[] = []
+const NO_ATTACHMENTS: PendingNativeChatImage[] = []
type Props = {
/** Controlled composer text — owned by the parent so dictation can write to it. */
@@ -37,6 +40,10 @@ type Props = {
onChangeText: (text: string) => void
onSend: (text: string) => Promise
onAttachImage?: () => void
+ /** Images picked-and-uploaded but not yet sent — shown as removable thumbnails
+ * and ridden along on the next send (desktop native-chat parity). */
+ attachments?: PendingNativeChatImage[]
+ onRemoveAttachment?: (id: string) => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
@@ -55,6 +62,8 @@ export function MobileNativeChatComposer({
onChangeText,
onSend,
onAttachImage,
+ attachments = NO_ATTACHMENTS,
+ onRemoveAttachment,
isAttaching = false,
onMicPress,
micActive = false,
@@ -76,7 +85,10 @@ export function MobileNativeChatComposer({
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
- const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching
+ // An attached image alone is a valid send (desktop parity), so the image rides
+ // along even when the user sends no accompanying text.
+ const canSend =
+ (trimmed.length > 0 || attachments.length > 0) && !disabled && !sending && !isAttaching
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo(() => {
@@ -145,6 +157,35 @@ export function MobileNativeChatComposer({
) : null}
+ {attachments.length > 0 ? (
+
+ {attachments.map((attachment) => (
+
+
+ {onRemoveAttachment ? (
+ onRemoveAttachment(attachment.id)}
+ hitSlop={8}
+ >
+
+
+ ) : null}
+
+ ))}
+
+ ) : null}
{onAttachImage ? (
{
+ const React = await import('react')
+ return {
+ Image: 'Image',
+ Pressable: 'Pressable',
+ Text: ({ children, ...props }: { children?: unknown }) =>
+ React.createElement('Text', props, children),
+ View: ({ children, ...props }: { children?: unknown }) =>
+ React.createElement('View', props, children),
+ StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
+ }
+})
+vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
+vi.mock('lucide-react-native', () => ({
+ ArrowUp: 'ArrowUp',
+ ChevronDown: 'ChevronDown',
+ Copy: 'Copy',
+ SquareChevronRight: 'SquareChevronRight'
+}))
+vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
+
+import { MobileNativeChatMessage } from './MobileNativeChatMessage'
+
+function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
+ return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
+}
+
+describe('MobileNativeChatMessage image-ref rendering', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ })
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function render(message: NativeChatMessage): ReactTestRenderer {
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(MobileNativeChatMessage, { message }))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ return renderer!
+ }
+
+ it('renders a loadable preview URI as an image thumbnail', () => {
+ const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
+ const image = tree.root.findByType('Image' as never)
+ expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
+ expect(image.props.accessibilityLabel).toBe('a photo')
+ })
+
+ it('prefers the url over the path when both are present', () => {
+ const tree = render(
+ userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
+ )
+ expect(tree.root.findByType('Image' as never).props.source).toEqual({
+ uri: 'file:///local.jpg'
+ })
+ })
+
+ it('falls back to a text placeholder for a bare host path', () => {
+ // A host temp path (e.g. on an SSH host) is not loadable on the device.
+ const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
+ expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
+ const texts = tree.root
+ .findAllByType('Text' as never)
+ .map((node) => String(node.children.join('')))
+ expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
+ })
+})
diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx
index eadfd2043..d8458f1b8 100644
--- a/mobile/src/session/MobileNativeChatMessage.tsx
+++ b/mobile/src/session/MobileNativeChatMessage.tsx
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, useState } from 'react'
-import { Pressable, Text, View } from 'react-native'
+import { Image, Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
@@ -13,6 +13,7 @@ import {
type ToolPair
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
+import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import {
@@ -160,6 +161,19 @@ function Prose({
)
}
if (isImageRefBlock(block)) {
+ // A local preview (composer echo) or real URL renders as a thumbnail; a bare
+ // host path (not loadable on the device) falls back to a text placeholder.
+ const uri = block.url ?? block.path
+ if (isRenderableImageUri(uri)) {
+ return (
+
+ )
+ }
return (
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx
index 6acf2d305..b1fcfd4c3 100644
--- a/mobile/src/session/MobileNativeChatOverlay.tsx
+++ b/mobile/src/session/MobileNativeChatOverlay.tsx
@@ -1,11 +1,13 @@
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
+import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
type Props = {
controller: MobileNativeChatController
- onAttachImage: () => void
- isAttaching: boolean
+ /** Native-chat image attachments: picking adds a composer chip, and sending
+ * rides the pending images along with the message text (desktop parity). */
+ images: MobileNativeChatImageAttachments
onMicPress: () => void
micActive: boolean
dictationMode: 'toggle' | 'hold'
@@ -19,8 +21,7 @@ type Props = {
* view toggles while the native surface owns the visible composer. */
export function MobileNativeChatOverlay({
controller,
- onAttachImage,
- isAttaching,
+ images,
onMicPress,
micActive,
dictationMode,
@@ -54,12 +55,14 @@ export function MobileNativeChatOverlay({
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
- onSend={controller.handleNativeChatSend}
+ onSend={images.sendNativeChat}
pending={controller.chatPending}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
- onAttachImage={onAttachImage}
- isAttaching={isAttaching}
+ onAttachImage={() => void images.attachImage('library')}
+ attachments={images.attachments}
+ onRemoveAttachment={images.removeAttachment}
+ isAttaching={images.isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx
index 1cfe37e5d..f2a23910a 100644
--- a/mobile/src/session/MobileNativeChatView.tsx
+++ b/mobile/src/session/MobileNativeChatView.tsx
@@ -17,11 +17,13 @@ import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
- mobileNativeChatEmptyState
+ mobileNativeChatEmptyState,
+ type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
+import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
@@ -53,11 +55,15 @@ type Props = {
onLoadEarlier?: () => void
onSend: (text: string) => Promise
/** Optimistic queued sends (owned by the route so they survive view switches). */
- pending: Array<{ id: string; text: string }>
+ /** Optimistic user echoes, including any ridden-along image preview URIs. */
+ pending: MobileNativeChatPendingItem[]
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
onAttachImage?: () => void
+ /** Pending image attachments shown as composer thumbnails until the next send. */
+ attachments?: PendingNativeChatImage[]
+ onRemoveAttachment?: (id: string) => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
@@ -103,6 +109,8 @@ export function MobileNativeChatView({
composerText,
onComposerTextChange,
onAttachImage,
+ attachments,
+ onRemoveAttachment,
isAttaching,
onMicPress,
micActive,
@@ -403,6 +411,8 @@ export function MobileNativeChatView({
onChangeText={onComposerTextChange}
onSend={handleSend}
onAttachImage={onAttachImage}
+ attachments={attachments}
+ onRemoveAttachment={onRemoveAttachment}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts
index be1b16a6c..1a582bde3 100644
--- a/mobile/src/session/mobile-image-source-picker.test.ts
+++ b/mobile/src/session/mobile-image-source-picker.test.ts
@@ -53,7 +53,10 @@ describe('pickMobileImage', () => {
createFile: file.createFile
})
- expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64') })
+ expect(result).toEqual({
+ base64: Buffer.from(bytes).toString('base64'),
+ uri: 'file:///x.jpg'
+ })
expect(launchLibrary).toHaveBeenCalledWith(
expect.objectContaining({ base64: false, allowsMultipleSelection: false })
)
@@ -91,7 +94,10 @@ describe('pickMobileImage', () => {
createFile: file.createFile
})
- expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64') })
+ expect(result).toEqual({
+ base64: Buffer.from(bytes).toString('base64'),
+ uri: 'file:///doc.png'
+ })
expect(launchFiles).toHaveBeenCalledWith(
expect.objectContaining({ copyToCacheDirectory: true })
)
@@ -167,7 +173,10 @@ describe('pickMobileImage', () => {
}),
createFile: file.createFile
})
- expect(result).toEqual({ base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64') })
+ expect(result).toEqual({
+ base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'),
+ uri: 'file:///chunked.png'
+ })
expect(file.close).toHaveBeenCalledTimes(1)
})
})
diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts
index fe0c2e91a..bf9c529cc 100644
--- a/mobile/src/session/mobile-image-source-picker.ts
+++ b/mobile/src/session/mobile-image-source-picker.ts
@@ -13,6 +13,9 @@ export type MobileImageSource = 'library' | 'files'
export type PickedMobileImage = {
// Raw base64 (no data: prefix); fed straight into the existing upload pipeline.
readonly base64: string
+ // Local file URI of the picked asset — used only to render a composer preview
+ // thumbnail (the host upload uses `base64`); absent when the source can't supply one.
+ readonly uri?: string
}
export class ImageLibraryPermissionError extends Error {
@@ -104,7 +107,7 @@ async function pickFromLibrary(
if (!base64) {
return null
}
- return { base64 }
+ return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) }
}
async function pickFromFiles(
@@ -124,7 +127,7 @@ async function pickFromFiles(
return null
}
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
- return base64 ? { base64 } : null
+ return base64 ? { base64, uri: asset.uri } : null
}
export async function pickMobileImage(
diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts
new file mode 100644
index 000000000..1af010ff5
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts
@@ -0,0 +1,103 @@
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import {
+ isImageSourceUserTurn,
+ stripImagePromptMarker
+} from './mobile-native-chat-image-transcript-markers'
+
+/** An ack-lost ('unknown' outcome) send held until its transcript echo lands or
+ * the deadline surfaces the uncertainty. */
+export type UnconfirmedSend = {
+ draftKey: string
+ pendingKey: string | null
+ text: string
+ normalizedText: string
+ baselineTailMessageId: string | null
+ deadline: ReturnType | null
+}
+
+export function normalizedUserText(message: NativeChatMessage): string | null {
+ if (message.role !== 'user') {
+ return null
+ }
+ const text = message.blocks
+ .filter((block) => block.type === 'text')
+ .map((block) => (block.type === 'text' ? block.text : ''))
+ .join('')
+ // Claude echoes a captioned image send as `[Image #1] caption` — the sent
+ // text must still match its echo, so strip the marker before comparing.
+ const stripped = stripImagePromptMarker(text).trim()
+ return stripped || null
+}
+
+export function countUserTextOccurrences(
+ messages: readonly NativeChatMessage[],
+ text: string
+): number {
+ let count = 0
+ for (const message of messages) {
+ if (normalizedUserText(message) === text) {
+ count++
+ }
+ }
+ return count
+}
+
+/** Number of `[Image: source: …]` echo turns strictly after `tailId` (or the
+ * whole transcript when the tail was paginated out). An image-only send has no
+ * caption to match, so it reconciles by ordinal against this count — counting
+ * only image echoes keeps an unrelated text send's echo from clearing it. */
+export function countImageSourceTurnsAfter(
+ messages: readonly NativeChatMessage[],
+ tailId: string | null
+): number {
+ const tailIndex = tailId ? messages.findIndex((message) => message.id === tailId) : -1
+ let count = 0
+ for (let i = tailIndex + 1; i < messages.length; i++) {
+ const message = messages[i]
+ if (message && isImageSourceUserTurn(message)) {
+ count++
+ }
+ }
+ return count
+}
+
+export function findLandedUnconfirmedSends(
+ messages: readonly NativeChatMessage[],
+ entries: readonly UnconfirmedSend[]
+): UnconfirmedSend[] {
+ // Why: pagination prepends old equal text; only unclaimed matches after each
+ // captured tail prove new echoes. User turns are keyed by text; an image echo
+ // (`[Image: source: …]` or no text) keys under '' so an empty-text send can
+ // claim it.
+ const messageIndexById = new Map()
+ const userMessagesByText = new Map>()
+ for (const [index, message] of messages.entries()) {
+ messageIndexById.set(message.id, index)
+ if (message.role !== 'user') {
+ continue
+ }
+ const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '')
+ const current = userMessagesByText.get(key) ?? []
+ current.push({ id: message.id, index })
+ userMessagesByText.set(key, current)
+ }
+
+ const claimedMessageIds = new Set()
+ const landed: UnconfirmedSend[] = []
+ for (const entry of entries) {
+ const tailIndex = entry.baselineTailMessageId
+ ? messageIndexById.get(entry.baselineTailMessageId)
+ : -1
+ if (tailIndex === undefined) {
+ continue
+ }
+ const echo = userMessagesByText
+ .get(entry.normalizedText)
+ ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id))
+ if (echo) {
+ claimedMessageIds.add(echo.id)
+ landed.push(entry)
+ }
+ }
+ return landed
+}
diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts
new file mode 100644
index 000000000..57c3f67bd
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse, RpcSuccess } from '../transport/types'
+import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment'
+
+function ok(id: string, result: unknown): RpcSuccess {
+ return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function methodNotFound(id: string): RpcResponse {
+ return {
+ id,
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ }
+}
+
+function clientWithResponses(responses: RpcResponse[]): Pick & {
+ calls: { method: string; params: unknown }[]
+} {
+ const calls: { method: string; params: unknown }[] = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+describe('uploadMobileNativeChatImage', () => {
+ it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => {
+ const client = clientWithResponses([
+ methodNotFound('start'),
+ ok('save', '/tmp/orca-attach.png')
+ ])
+
+ const result = await uploadMobileNativeChatImage('library', {
+ client,
+ getConnectionId: async () => 'conn-7',
+ pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' })
+ })
+
+ expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' })
+ // Native chat defers the paste to submit — nothing is sent to the terminal here.
+ expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
+ const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile')
+ expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' })
+ })
+
+ it('returns null when the picker is cancelled and uploads nothing', async () => {
+ const client = clientWithResponses([])
+
+ const result = await uploadMobileNativeChatImage('library', {
+ client,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue(null)
+ })
+
+ expect(result).toBeNull()
+ expect(client.calls).toEqual([])
+ })
+
+ it('falls back to an inline data uri for the preview when the picker omits a uri', async () => {
+ const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')])
+
+ const result = await uploadMobileNativeChatImage('files', {
+ client,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' })
+ })
+
+ expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' })
+ })
+
+ it('signals upload start only after a real image is picked', async () => {
+ const onUploadStart = vi.fn()
+ const cancelledClient = clientWithResponses([])
+ await uploadMobileNativeChatImage('library', {
+ client: cancelledClient,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue(null),
+ onUploadStart
+ })
+ expect(onUploadStart).not.toHaveBeenCalled()
+
+ const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')])
+ await uploadMobileNativeChatImage('library', {
+ client,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }),
+ onUploadStart
+ })
+ expect(onUploadStart).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts
new file mode 100644
index 000000000..f81b9d2ad
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-attachment.ts
@@ -0,0 +1,46 @@
+import type { RpcClient } from '../transport/rpc-client'
+import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image'
+// Type-only import so this module (and its unit test) stays free of the expo/
+// react-native picker chain; the concrete `pickImage` is injected by the hook.
+import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
+
+/** A picked-and-uploaded image held in the native-chat composer until submit.
+ * `path` is the host temp file pasted into the agent on send; `previewUri` is a
+ * local URI used only to render the composer thumbnail. */
+export type PendingNativeChatImage = {
+ readonly id: string
+ readonly path: string
+ readonly previewUri: string
+}
+
+export type UploadNativeChatImageDeps = {
+ readonly client: Pick
+ readonly getConnectionId: () => Promise
+ // Injected so this module stays free of expo/react-native imports (unit-testable).
+ readonly pickImage: (source: MobileImageSource) => Promise
+ // Fired once the user has picked an image and the host upload is about to start —
+ // lets the UI show the attach spinner only for the transfer, not the picker.
+ readonly onUploadStart?: () => void
+}
+
+/** Picks an image and uploads it to the host, returning the host path + a local
+ * preview URI — but does NOT paste it into the terminal. Unlike the terminal
+ * attach flow, native chat holds the image as a composer chip and rides it along
+ * on submit (desktop parity), so the chip and the agent input never diverge.
+ * Returns null when the user cancels the picker. */
+export async function uploadMobileNativeChatImage(
+ source: MobileImageSource,
+ { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps
+): Promise | null> {
+ const picked = await pickImage(source)
+ if (!picked) {
+ return null
+ }
+ onUploadStart?.()
+ const connectionId = await getConnectionId()
+ const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId })
+ // Prefer the picker's local URI for the thumbnail; fall back to an inline data
+ // URI when the source omitted one (RN renders both).
+ const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
+ return { path, previewUri }
+}
diff --git a/mobile/src/session/mobile-native-chat-image-preview.test.ts b/mobile/src/session/mobile-native-chat-image-preview.test.ts
new file mode 100644
index 000000000..64ec62d57
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-preview.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { isRenderableImageUri } from './mobile-native-chat-image-preview'
+
+describe('isRenderableImageUri', () => {
+ it('accepts local previews and real URLs the device can load', () => {
+ for (const uri of [
+ 'file:///var/mobile/a.jpg',
+ 'data:image/png;base64,AAAA',
+ 'content://media/1',
+ 'blob:abc',
+ 'http://host/a.png',
+ 'https://host/a.png'
+ ]) {
+ expect(isRenderableImageUri(uri)).toBe(true)
+ }
+ })
+
+ it('rejects bare host paths (not loadable on the device) and empty values', () => {
+ for (const uri of [
+ '/tmp/orca-attach.png',
+ 'C:\\tmp\\a.png',
+ 'orca-attach.png',
+ '',
+ undefined
+ ]) {
+ expect(isRenderableImageUri(uri)).toBe(false)
+ }
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-image-preview.ts b/mobile/src/session/mobile-native-chat-image-preview.ts
new file mode 100644
index 000000000..8062ac5b8
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-preview.ts
@@ -0,0 +1,9 @@
+// A URI RN can actually load: a local composer/echo preview (file://,
+// data:, content://, blob:) or a real remote URL. A bare host path from the
+// transcript (e.g. /tmp/x.png on an SSH host) is not loadable on the device, so
+// it stays a text placeholder instead of a broken image.
+const RENDERABLE_IMAGE_URI = /^(file:|data:|https?:|content:|blob:)/i
+
+export function isRenderableImageUri(uri: string | undefined): uri is string {
+ return typeof uri === 'string' && RENDERABLE_IMAGE_URI.test(uri)
+}
diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts
new file mode 100644
index 000000000..368cd6a87
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-send.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse, RpcSuccess } from '../transport/types'
+import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
+
+function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
+ return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
+}
+
+function clientWithResponses(responses: RpcResponse[]): Pick & {
+ calls: { method: string; params: Record }[]
+} {
+ const calls: { method: string; params: Record }[] = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params: params as Record })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+describe('pasteMobileNativeChatImagePaths', () => {
+ it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => {
+ const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
+
+ const ok = await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: 'term-1',
+ deviceToken: 'device-9',
+ imagePaths: ['/tmp/a.png', '/tmp/b.png']
+ })
+
+ expect(ok).toBe(true)
+ expect(client.calls).toHaveLength(3)
+ // Leading Ctrl+U clears any stale input so a retry can't duplicate the image.
+ expect(client.calls[0]).toEqual({
+ method: 'terminal.send',
+ params: {
+ terminal: 'term-1',
+ text: '\x15',
+ enter: false,
+ client: { id: 'device-9', type: 'mobile' }
+ }
+ })
+ 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~')
+ })
+
+ it('stops and reports failure as soon as a paste is rejected', async () => {
+ // Clear accepted, first image paste rejected.
+ const client = clientWithResponses([sendResult(true), sendResult(false)])
+
+ const ok = await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: 'term-1',
+ deviceToken: null,
+ imagePaths: ['/tmp/a.png', '/tmp/b.png']
+ })
+
+ expect(ok).toBe(false)
+ // Never attempts the second path after the first is rejected.
+ expect(client.calls).toHaveLength(2)
+ expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
+ expect(client.calls[0]?.params).not.toHaveProperty('client')
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts
new file mode 100644
index 000000000..6d163b41f
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-send.ts
@@ -0,0 +1,54 @@
+import type { RpcClient } from '../transport/rpc-client'
+import { buildMobileImagePastePayload } from './mobile-clipboard-image'
+import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
+
+// Give the agent TUI a beat to register each bracketed image paste before the
+// message text + Enter arrive, so the image attaches instead of being treated as
+// part of the prompt body (mirrors desktop's NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS).
+export const MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS = 300
+
+// Ctrl+U kills the agent's unsubmitted input line. Sent before pasting so a retry
+// after a rejected body/Enter can't leave a stale image paste that then rides along
+// with (and duplicates) the next attempt — matches desktop clearUnsubmittedAgentInput.
+const MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
+
+type MobileTerminalClient = { id: string; type: 'mobile' }
+
+type PasteImagesArgs = {
+ readonly client: Pick
+ readonly terminal: string
+ readonly deviceToken: string | null
+ readonly imagePaths: readonly string[]
+}
+
+/** Clears the agent's unsubmitted input line, then pastes each uploaded image
+ * path into the terminal as a bracketed paste (no Enter) — the same payload
+ * desktop native chat rides along on submit. The leading clear keeps a retry
+ * idempotent after a failed body/Enter. Returns false as soon as the host rejects
+ * one, so the caller can abort before Enter. */
+export async function pasteMobileNativeChatImagePaths({
+ client,
+ terminal,
+ deviceToken,
+ imagePaths
+}: PasteImagesArgs): Promise {
+ const mobileClient: MobileTerminalClient | null = deviceToken
+ ? { id: deviceToken, type: 'mobile' }
+ : null
+ const clientField = mobileClient ? { client: mobileClient } : {}
+ for (const text of [
+ MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
+ ...imagePaths.map(buildMobileImagePastePayload)
+ ]) {
+ const response = await client.sendRequest('terminal.send', {
+ terminal,
+ text,
+ enter: false,
+ ...clientField
+ })
+ if (!isTerminalSendRpcAccepted(response)) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts
new file mode 100644
index 000000000..f8f0ed6b8
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts
@@ -0,0 +1,21 @@
+// Single-sources the desktop marker logic (pure functions over shared types):
+// Claude records an attached image as `[Image: source: /path]` (+ `[Image #N]`
+// prefix on the caption turn), and both render and echo reconciliation must
+// agree with desktop on how those marker turns are interpreted.
+export {
+ imageSourcePathFromText,
+ normalizeImageTranscriptMessages,
+ stripImagePromptMarker
+} from '../../../src/renderer/src/components/native-chat/native-chat-image-transcript-markers'
+import { imageSourcePathFromText } from '../../../src/renderer/src/components/native-chat/native-chat-image-transcript-markers'
+import { isTextBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
+
+/** A raw (un-normalized) transcript user turn that is an image-source marker —
+ * the echo shape of an image riding along on a send. */
+export function isImageSourceUserTurn(message: NativeChatMessage): boolean {
+ if (message.role !== 'user' || message.blocks.length !== 1) {
+ return false
+ }
+ const block = message.blocks[0]
+ return block !== undefined && isTextBlock(block) && imageSourcePathFromText(block.text) !== null
+}
diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts
index c67ffb738..41d77564f 100644
--- a/mobile/src/session/mobile-native-chat-message-styles.ts
+++ b/mobile/src/session/mobile-native-chat-message-styles.ts
@@ -141,6 +141,14 @@ export const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: TEXT_SIZE
},
+ imageThumb: {
+ width: 200,
+ height: 150,
+ borderRadius: radii.card,
+ backgroundColor: colors.bgRaised,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle
+ },
diff: {
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts
index e93397122..4dabff8f6 100644
--- a/mobile/src/session/mobile-native-chat-render-data.test.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.test.ts
@@ -63,6 +63,55 @@ describe('buildMobileNativeChatData', () => {
expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }])
})
+ it('renders a pending send with images as text followed by image-ref thumbnails', () => {
+ const { data } = buildMobileNativeChatData({
+ messages: [],
+ pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
+ })
+ const last = data[data.length - 1]
+ expect(last.role).toBe('user')
+ expect(last.blocks).toEqual([
+ { type: 'text', text: 'look' },
+ { type: 'image-ref', url: 'file:///a.jpg' },
+ { type: 'image-ref', url: 'file:///b.jpg' }
+ ])
+ })
+
+ it('renders an image-only pending send (no text) as just the thumbnail', () => {
+ const { data } = buildMobileNativeChatData({
+ messages: [],
+ pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
+ })
+ expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
+ })
+
+ it('folds transcript image marker turns into image-ref blocks (desktop parity)', () => {
+ // Claude records an attached image as `[Image: source: /path]` + an
+ // `[Image #1] `-prefixed caption turn; the fold must merge them into one
+ // user turn with an image-ref block instead of showing raw marker text.
+ const { data } = buildMobileNativeChatData({
+ messages: [
+ user('u1', '[Image: source: /tmp/a.png]'),
+ user('u2', '[Image #1] look at this'),
+ assistant('a1', 'nice photo')
+ ],
+ pending: []
+ })
+ const merged = data.find((message) => message.role === 'user')
+ expect(merged?.blocks).toEqual([
+ { type: 'image-ref', path: '/tmp/a.png' },
+ { type: 'text', text: 'look at this' }
+ ])
+ })
+
+ it('renders a lone image marker turn (no caption) as an image-ref block', () => {
+ const { data } = buildMobileNativeChatData({
+ messages: [user('u1', '[Image: source: /tmp/a.png]')],
+ pending: []
+ })
+ expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
+ })
+
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [user('u1', 'hi')],
diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts
index 74ca01939..068050fc4 100644
--- a/mobile/src/session/mobile-native-chat-render-data.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.ts
@@ -5,6 +5,7 @@ import {
} from '../../../src/shared/native-chat-empty-state'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
+import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import { stripNoiseMessages } from './mobile-native-chat-noise'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
@@ -34,6 +35,14 @@ export function mobileNativeChatEmptyState(
}
}
+/** An optimistic user echo: the text and/or the local preview URIs of any images
+ * ridden along on the send, shown until the transcript catches up. */
+export type MobileNativeChatPendingItem = {
+ id: string
+ text: string
+ images?: string[]
+}
+
/** Derive the list data from the raw transcript: fold tool turns into the
* assistant turn, optionally append a synthetic streaming bubble, then the
* route-owned optimistic "queued" messages at the tail. Returns the
@@ -45,14 +54,16 @@ export function buildMobileNativeChatData({
}: {
messages: NativeChatMessage[]
streamingText?: string
- pending: Array<{ id: string; text: string }>
+ pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
- return foldToolMessages(stripNoiseMessages(messages))
+ // Normalize first (desktop assembler parity): image marker turns fold into
+ // image-ref blocks instead of rendering as raw `[Image: …]` text.
+ return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
}
export function buildMobileNativeChatTransientData({
@@ -62,7 +73,7 @@ export function buildMobileNativeChatTransientData({
}: {
folded: NativeChatMessage[]
streamingText?: string
- pending: Array<{ id: string; text: string }>
+ pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
// Only show the streaming bubble while its text leads the transcript — once the
// real assistant turn lands with the same text, drop the synthetic one.
@@ -83,7 +94,12 @@ export function buildMobileNativeChatTransientData({
...pending.map((p) => ({
id: p.id,
role: 'user' as const,
- blocks: [{ type: 'text' as const, text: p.text }],
+ // Text first (when present), then a thumbnail per ridden-along image so the
+ // sent photo shows immediately, before the transcript echo lands.
+ blocks: [
+ ...(p.text ? [{ type: 'text' as const, text: p.text }] : []),
+ ...(p.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri }))
+ ],
timestamp: null,
source: 'transcript' as const
}))
diff --git a/mobile/src/session/mobile-native-chat-scope-key.ts b/mobile/src/session/mobile-native-chat-scope-key.ts
new file mode 100644
index 000000000..e5b3b4ef5
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-scope-key.ts
@@ -0,0 +1,10 @@
+/** Identity of a native-chat composer surface: host + worktree + tab. Drafts
+ * and pending image chips are both keyed by it, so a tab switch cannot leak
+ * one tab's composer state into another tab's terminal. */
+export function mobileNativeChatScopeKey(
+ hostId: string,
+ worktreeId: string,
+ tabId: string | null
+): string | null {
+ return tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
+}
diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts
new file mode 100644
index 000000000..92d2e6b0a
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts
@@ -0,0 +1,140 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+
+const acceptSend = vi.fn()
+const captureSendOrigin = vi.fn()
+const holdUnconfirmedSend = vi.fn()
+
+// The controller composes many session hooks; each is mocked to a minimal shape
+// so this test isolates the send seam (outcome -> drafts accounting).
+vi.mock('./use-mobile-session-view-mode', () => ({
+ useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() })
+}))
+vi.mock('./use-mobile-native-chat-session', () => ({
+ useMobileNativeChatSession: () => ({ messages: [] })
+}))
+vi.mock('./use-mobile-native-chat-drafts', () => ({
+ useMobileNativeChatDrafts: () => ({
+ composerText: '',
+ setComposerText: vi.fn(),
+ pending: [],
+ captureSendOrigin,
+ acceptSend,
+ holdUnconfirmedSend
+ })
+}))
+vi.mock('./use-mobile-native-chat-prompts', () => ({
+ useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
+}))
+vi.mock('./use-mobile-native-chat-answer-send', () => ({
+ useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
+}))
+vi.mock('./mobile-native-chat-permission-send', () => ({
+ useMobileNativeChatPermissionSend: () => vi.fn()
+}))
+vi.mock('./use-mobile-native-chat-stop', () => ({
+ useMobileNativeChatStop: () => vi.fn()
+}))
+vi.mock('./use-mobile-native-chat-file-search', () => ({
+ useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() })
+}))
+vi.mock('./mobile-native-chat-send', () => ({
+ sendMobileNativeChatMessageWithOutcome: vi.fn()
+}))
+
+import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
+import {
+ useMobileNativeChatController,
+ type MobileNativeChatController
+} from './use-mobile-native-chat-controller'
+
+const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
+
+const ORIGIN = {
+ draftKey: 'h\0w\0tab-1',
+ pendingKey: 'h\0w\0tab-1\0session-1',
+ normalizedText: 'look',
+ baselineOccurrences: 0,
+ baselineTailMessageId: null
+}
+
+describe('useMobileNativeChatController handleNativeChatSend', () => {
+ let renderer: ReactTestRenderer | null = null
+ let controller: MobileNativeChatController | null = null
+ const onSendError = vi.fn()
+
+ function Harness(): null {
+ controller = useMobileNativeChatController({
+ client: {} as RpcClient,
+ hostId: 'h',
+ worktreeId: 'w',
+ activeSessionTab: null,
+ activeSessionTabId: 'tab-1',
+ activeHandleRef: { current: 'term-1' },
+ deviceTokenRef: { current: null },
+ nativeChatTranscriptIsLocalReadable: true,
+ nativeChatInputLeaseReady: true,
+ onSendError
+ })
+ return null
+ }
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ vi.clearAllMocks()
+ captureSendOrigin.mockReturnValue(ORIGIN)
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ })
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ controller = null
+ })
+
+ it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => {
+ sendWithOutcome.mockResolvedValue('accepted')
+ let accepted = false
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
+ })
+ expect(accepted).toBe(true)
+ expect(acceptSend).toHaveBeenCalledWith(ORIGIN, 'look', ['file:///a.jpg'])
+ })
+
+ it('holds an unknown-outcome send without posting the optimistic echo', async () => {
+ sendWithOutcome.mockResolvedValue('unknown')
+ let accepted = false
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
+ })
+ expect(accepted).toBe(true)
+ expect(acceptSend).not.toHaveBeenCalled()
+ expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
+ })
+
+ it('reports a rejected send and posts no echo', async () => {
+ sendWithOutcome.mockResolvedValue('rejected')
+ let accepted = true
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
+ })
+ expect(accepted).toBe(false)
+ expect(acceptSend).not.toHaveBeenCalled()
+ expect(onSendError).toHaveBeenCalledWith('Message not sent')
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index f10f41698..a2ec8c1ce 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -19,7 +19,10 @@ import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
-import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
+import {
+ useMobileNativeChatDrafts,
+ type MobileNativeChatPendingMessage
+} from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
@@ -38,7 +41,7 @@ export type MobileNativeChatController = {
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch>
- chatPending: Array<{ id: string; text: string }>
+ chatPending: MobileNativeChatPendingMessage[]
nativeChatSession: ReturnType
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
@@ -55,7 +58,7 @@ export type MobileNativeChatController = {
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
- handleNativeChatSend: (text: string) => Promise
+ handleNativeChatSend: (text: string, images?: string[]) => Promise
}
/** Owns mobile native-chat state and teardown outside the already dense session
@@ -222,7 +225,7 @@ export function useMobileNativeChatController(args: {
})
const handleNativeChatSend = useCallback(
- async (text: string): Promise => {
+ async (text: string, images?: string[]): Promise => {
const handle = activeHandleRef.current
const origin = captureSendOrigin(text)
if (!client || !handle || !origin || !nativeChatInputLeaseReady) {
@@ -249,7 +252,9 @@ export function useMobileNativeChatController(args: {
onSendError('Message not sent')
return false
}
- acceptSend(origin, text)
+ // `images` are local preview URIs for the optimistic echo only — the actual
+ // image bytes already rode along as a bracketed paste before this text send.
+ acceptSend(origin, text, images)
return true
},
[
diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts
index 6ef6f0663..0bda8df13 100644
--- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts
@@ -120,6 +120,126 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
})
+ it('keeps an image-only echo through an agent reply, clearing only when the user turn lands', async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const origin = state?.captureSendOrigin('')
+ act(() => {
+ if (origin) {
+ state?.acceptSend(origin, '', ['file:///a.jpg'])
+ }
+ })
+ // The echo carries the preview thumbnail and has no text to match against.
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
+
+ // An agent reply grows the transcript but must NOT clear the photo echo early.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'nice photo')]
+ })
+ )
+ )
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
+
+ // The user's own image echo landing (Claude records it as an
+ // `[Image: source: …]` turn) clears it.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ assistantTextMessage('a2', 'nice photo'),
+ userTextMessage('u1', '[Image: source: /tmp/a.png]')
+ ]
+ })
+ )
+ )
+ expect(state?.pending).toEqual([])
+ })
+
+ it("keeps an image-only echo when an unrelated text send's echo lands", async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const textOrigin = state?.captureSendOrigin('ping')
+ const imageOrigin = state?.captureSendOrigin('')
+ act(() => {
+ if (textOrigin && imageOrigin) {
+ state?.acceptSend(textOrigin, 'ping')
+ state?.acceptSend(imageOrigin, '', ['file:///a.jpg'])
+ }
+ })
+ expect(state?.pending).toHaveLength(2)
+
+ // The text echo lands first: it must clear only the text pending — a user
+ // turn that is not an image echo cannot reconcile the photo.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [assistantTextMessage('a1', 'hi'), userTextMessage('u1', 'ping')]
+ })
+ )
+ )
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
+
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ userTextMessage('u1', 'ping'),
+ userTextMessage('u2', '[Image: source: /tmp/a.png]')
+ ]
+ })
+ )
+ )
+ expect(state?.pending).toEqual([])
+ })
+
+ it('reconciles a captioned image echo that carries the [Image #N] marker', async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const origin = state?.captureSendOrigin('look at this')
+ act(() => {
+ if (origin) {
+ state?.acceptSend(origin, 'look at this', ['file:///a.jpg'])
+ }
+ })
+ expect(state?.pending).toHaveLength(1)
+
+ // Claude echoes a captioned image send as two turns: the source marker and
+ // the caption prefixed with `[Image #1] ` — the pending must still match.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ userTextMessage('u1', '[Image: source: /tmp/a.png]'),
+ userTextMessage('u2', '[Image #1] look at this')
+ ]
+ })
+ )
+ )
+ expect(state?.pending).toEqual([])
+ })
+
it('does not reconcile a repeated send against an older identical turn', async () => {
await mount('a')
await act(async () =>
@@ -201,6 +321,84 @@ describe('useMobileNativeChatDrafts', () => {
}
})
+ it('reconciles an image-only unconfirmed send against the next user turn (no false warning)', async () => {
+ vi.useFakeTimers()
+ try {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ // Image-only send: empty text, so it can only reconcile against a new user turn.
+ const origin = state?.captureSendOrigin('')
+ const onUnconfirmed = vi.fn()
+ act(() => {
+ if (origin) {
+ state?.holdUnconfirmedSend(origin, '', onUnconfirmed)
+ }
+ })
+
+ // An agent reply must not confirm it...
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok')]
+ })
+ )
+ )
+ // ...but the user's own turn landing does, so the deadline never warns.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ assistantTextMessage('a2', 'ok'),
+ userTextMessage('u1', '')
+ ]
+ })
+ )
+ )
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(onUnconfirmed).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('clears image-only echoes one per landed user turn, not all at once', async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const origin = state?.captureSendOrigin('')
+ act(() => {
+ if (origin) {
+ state?.acceptSend(origin, '', ['file:///a.jpg'])
+ state?.acceptSend(origin, '', ['file:///b.jpg'])
+ }
+ })
+ expect(state?.pending).toHaveLength(2)
+
+ // Only one image echo has landed — exactly one photo reconciles.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ userTextMessage('u1', '[Image: source: /tmp/a.png]')
+ ]
+ })
+ )
+ )
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///b.jpg']])
+ })
+
it('clears immediately when the transcript echo beat the ambiguous RPC rejection', async () => {
vi.useFakeTimers()
try {
diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts
index aa954d526..a3b3708b9 100644
--- a/mobile/src/session/use-mobile-native-chat-drafts.ts
+++ b/mobile/src/session/use-mobile-native-chat-drafts.ts
@@ -1,10 +1,25 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import {
+ countImageSourceTurnsAfter,
+ countUserTextOccurrences,
+ findLandedUnconfirmedSends,
+ normalizedUserText,
+ type UnconfirmedSend
+} from './mobile-native-chat-draft-reconcile'
+import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
export type MobileNativeChatPendingMessage = {
id: string
text: string
expectedOccurrence: number
+ /** Local preview URIs of images ridden along on the send, rendered as thumbnails
+ * on the echo bubble so the sent photo shows before the transcript catches up. */
+ images?: string[]
+ /** Transcript tail when sent — an image-only echo (no text to match) reconciles
+ * against new `[Image: source: …]` echo turns after this id, so pagination,
+ * agent replies, and unrelated text echoes can't clear it early. */
+ baselineTailMessageId: string | null
}
export type MobileNativeChatSendOrigin = {
draftKey: string
@@ -20,74 +35,6 @@ const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
// that delivery remains unconfirmed.
const UNCONFIRMED_SEND_DEADLINE_MS = 20_000
-type UnconfirmedSend = {
- draftKey: string
- pendingKey: string | null
- text: string
- normalizedText: string
- baselineTailMessageId: string | null
- deadline: ReturnType | null
-}
-
-function normalizedUserText(message: NativeChatMessage): string | null {
- if (message.role !== 'user') {
- return null
- }
- const text = message.blocks
- .filter((block) => block.type === 'text')
- .map((block) => (block.type === 'text' ? block.text : ''))
- .join('')
- .trim()
- return text || null
-}
-
-function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number {
- let count = 0
- for (const message of messages) {
- if (normalizedUserText(message) === text) {
- count++
- }
- }
- return count
-}
-
-function findLandedUnconfirmedSends(
- messages: readonly NativeChatMessage[],
- entries: readonly UnconfirmedSend[]
-): UnconfirmedSend[] {
- // Why: pagination prepends old equal text; only unclaimed matches after each captured tail prove new echoes.
- const messageIndexById = new Map()
- const userMessagesByText = new Map>()
- for (const [index, message] of messages.entries()) {
- messageIndexById.set(message.id, index)
- const text = normalizedUserText(message)
- if (text) {
- const current = userMessagesByText.get(text) ?? []
- current.push({ id: message.id, index })
- userMessagesByText.set(text, current)
- }
- }
-
- const claimedMessageIds = new Set()
- const landed: UnconfirmedSend[] = []
- for (const entry of entries) {
- const tailIndex = entry.baselineTailMessageId
- ? messageIndexById.get(entry.baselineTailMessageId)
- : -1
- if (tailIndex === undefined) {
- continue
- }
- const echo = userMessagesByText
- .get(entry.normalizedText)
- ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id))
- if (echo) {
- claimedMessageIds.add(echo.id)
- landed.push(entry)
- }
- }
- return landed
-}
-
export function useMobileNativeChatDrafts(args: {
hostId: string
worktreeId: string
@@ -99,7 +46,7 @@ export function useMobileNativeChatDrafts(args: {
setComposerText: Dispatch>
pending: MobileNativeChatPendingMessage[]
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
- acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void
+ acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
holdUnconfirmedSend: (
origin: MobileNativeChatSendOrigin,
text: string,
@@ -107,7 +54,7 @@ export function useMobileNativeChatDrafts(args: {
) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages } = args
- const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
+ const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
const [drafts, setDrafts] = useState>({})
const [pendingBySession, setPendingBySession] = useState<
@@ -154,36 +101,53 @@ export function useMobileNativeChatDrafts(args: {
[draftKey, pendingKey]
)
- const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
- // Why: an RPC may settle after a tab switch; mutate only the tab that
- // originated the send, without erasing edits typed after it began.
- setDrafts((previous) =>
- (previous[origin.draftKey] ?? '').trim() === text.trim()
- ? { ...previous, [origin.draftKey]: '' }
- : previous
- )
- // Why: the first prompt can be sent before the provider reports a session
- // id; clear its draft, but wait for an id before keying an optimistic echo.
- if (!origin.pendingKey) {
- return
- }
- const pendingKey = origin.pendingKey
- pendingCounterRef.current += 1
- setPendingBySession((previous) => {
- const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
- const earlierOutstanding = current.filter(
- (pending) =>
- pending.text.trim() === origin.normalizedText &&
- pending.expectedOccurrence > origin.baselineOccurrences
- ).length
- const pending = {
- id: `pending-${pendingCounterRef.current}`,
- text,
- expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1
+ const acceptSend = useCallback(
+ (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => {
+ // Why: an RPC may settle after a tab switch; mutate only the tab that
+ // originated the send, without erasing edits typed after it began.
+ setDrafts((previous) =>
+ (previous[origin.draftKey] ?? '').trim() === text.trim()
+ ? { ...previous, [origin.draftKey]: '' }
+ : previous
+ )
+ // Why: the first prompt can be sent before the provider reports a session
+ // id; clear its draft, but wait for an id before keying an optimistic echo.
+ if (!origin.pendingKey) {
+ return
}
- return { ...previous, [pendingKey]: [...current, pending] }
- })
- }, [])
+ const pendingKey = origin.pendingKey
+ pendingCounterRef.current += 1
+ setPendingBySession((previous) => {
+ const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
+ const earlierOutstanding = current.filter(
+ (pending) =>
+ pending.text.trim() === origin.normalizedText &&
+ pending.expectedOccurrence > origin.baselineOccurrences
+ ).length
+ // An empty-text send reconciles by image-echo ordinal: every outstanding
+ // send's ridden-along images echo as `[Image: source: …]` turns after
+ // this send's baseline tail, ahead of this send's own echo.
+ const expectedImageEchoOrdinal =
+ current.reduce(
+ (sum, pending) =>
+ sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)),
+ 0
+ ) + 1
+ const pending: MobileNativeChatPendingMessage = {
+ id: `pending-${pendingCounterRef.current}`,
+ text,
+ expectedOccurrence:
+ origin.normalizedText === ''
+ ? expectedImageEchoOrdinal
+ : origin.baselineOccurrences + earlierOutstanding + 1,
+ baselineTailMessageId: origin.baselineTailMessageId,
+ ...(images && images.length > 0 ? { images } : {})
+ }
+ return { ...previous, [pendingKey]: [...current, pending] }
+ })
+ },
+ []
+ )
// Why: a relay drop mid-send loses only the ack in the common case — the
// desktop already delivered the message. Hold the send instead of claiming
@@ -286,8 +250,16 @@ export function useMobileNativeChatDrafts(args: {
}
// Why: compare against the count captured before send; historical equal
// turns cannot clear a new echo, while duplicates land one occurrence each.
- const next = current.filter(
- (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
+ // An image-only echo has no text to match, so it reconciles by ORDINAL
+ // against the count of new `[Image: source: …]` echo turns after its
+ // baseline tail — text echoes are excluded so an unrelated outstanding
+ // text send cannot clear it. Ordinal-vs-count stays stable when the effect
+ // re-runs on the shrunken list, and ignores paginated-in history.
+ const next = current.filter((item) =>
+ item.text.trim() === ''
+ ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) <
+ item.expectedOccurrence
+ : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
)
if (next.length === current.length) {
return previous
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
new file mode 100644
index 000000000..7c91bc4cb
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
@@ -0,0 +1,532 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse, RpcSuccess } from '../transport/types'
+import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
+
+// Fully stub the picker so the real expo/react-native chain never loads under
+// the vitest transform (react-native ships Flow syntax rolldown can't parse).
+vi.mock('./mobile-image-source-picker', () => ({
+ pickMobileImage: vi.fn(),
+ ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {}
+}))
+
+import { pickMobileImage } from './mobile-image-source-picker'
+
+const pick = vi.mocked(pickMobileImage)
+
+function ok(id: string, result: unknown): RpcSuccess {
+ return { id, ok: true, result, _meta: { runtimeId: 'r' } }
+}
+function methodNotFound(id: string): RpcResponse {
+ return {
+ id,
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ }
+}
+function sendResult(accepted: boolean): RpcSuccess {
+ return { id: 'send', ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
+}
+
+function makeClient(responses: RpcResponse[]): Pick & {
+ calls: { method: string; params: Record }[]
+} {
+ const calls: { method: string; params: Record }[] = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params: params as Record })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+type HookArgs = Parameters[0]
+type Hook = ReturnType
+
+const SCOPE_A = 'h\0w\0tab-a'
+
+function baseArgs(overrides: Partial & Pick): HookArgs {
+ return {
+ activeHandleRef: { current: 'term-1' },
+ deviceTokenRef: { current: null },
+ getActiveWorktreeConnectionId: async () => null,
+ connState: 'connected',
+ scopeKey: SCOPE_A,
+ enabled: true,
+ showToast: vi.fn(),
+ baseSend: vi.fn().mockResolvedValue(true),
+ sleep: async () => {},
+ ...overrides
+ }
+}
+
+describe('useMobileNativeChatImageAttachments', () => {
+ let renderer: ReactTestRenderer | null = null
+ let hook: Hook | null = null
+
+ function Harness({ args }: { args: HookArgs }): null {
+ hook = useMobileNativeChatImageAttachments(args)
+ return null
+ }
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ pick.mockReset()
+ })
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ hook = null
+ })
+
+ function mount(args: HookArgs): void {
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(Harness, { args }))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ }
+
+ function update(args: HookArgs): void {
+ act(() => {
+ renderer!.update(createElement(Harness, { args }))
+ })
+ }
+
+ it('adds an uploaded image as a chip without pasting to the terminal', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ mount(
+ baseArgs({
+ client: client as unknown as RpcClient,
+ deviceTokenRef: { current: 'device-1' },
+ getActiveWorktreeConnectionId: async () => 'conn-1'
+ })
+ )
+
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ expect(hook!.attachments).toEqual([
+ { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.jpg' }
+ ])
+ expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false)
+ })
+
+ it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(true) // the image paste (enter:false)
+ ])
+ const order: string[] = []
+ const sleep = vi.fn(async () => {
+ order.push('settle')
+ })
+ const baseSend = vi.fn(async (t: string) => {
+ order.push(`text:${t}`)
+ return true
+ })
+ // Record each terminal write so the paste-before-settle order is asserted,
+ // not just implied by the call counts.
+ const trackedClient: Pick = {
+ sendRequest: (method, params) => {
+ if (method === 'terminal.send') {
+ order.push((params as { text?: string }).text === '\x15' ? 'clear' : 'paste')
+ }
+ return client.sendRequest(method, params)
+ }
+ }
+ mount(
+ baseArgs({
+ client: trackedClient as RpcClient,
+ deviceTokenRef: { current: 'device-1' },
+ baseSend,
+ sleep
+ })
+ )
+
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ let accepted = false
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('look at this')
+ })
+
+ expect(accepted).toBe(true)
+ const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
+ // Ctrl+U clear, then the bracketed image paste.
+ 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~',
+ enter: false
+ })
+ // 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.
+ expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg'])
+ // Chips clear once the send is accepted.
+ expect(hook!.attachments).toEqual([])
+ })
+
+ it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(true) // image paste
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
+
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = false
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('')
+ })
+
+ expect(accepted).toBe(true)
+ // Empty text still goes through baseSend (which submits the bare Enter) so the
+ // optimistic echo carries the preview URI.
+ expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'])
+ 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(hook!.attachments).toEqual([])
+ })
+
+ it('delegates straight to baseSend when there are no attachments', async () => {
+ const client = makeClient([])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
+
+ await act(async () => {
+ await hook!.sendNativeChat('just text')
+ })
+ expect(baseSend).toHaveBeenCalledWith('just text')
+ expect(client.calls).toHaveLength(0)
+ })
+
+ it('keeps the chips and does not submit when the image paste is rejected', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(false) // image paste rejected
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = true
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi')
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('keeps the chips and reports failure when the paste transport throws', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ // No terminal.send responses queued: the clear write throws (dropped transport).
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = true
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi')
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ // Attaching is allowed without the lease; only the send is gated on it.
+ mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = true
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi')
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent (disconnected)', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('scopes chips to the tab that attached them', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const args = baseArgs({ client: client as unknown as RpcClient, baseSend })
+ mount(args)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ expect(hook!.attachments).toHaveLength(1)
+
+ // Another tab sees no chip, and a send there is plain text — no image paste.
+ update({ ...args, scopeKey: 'h\0w\0tab-b' })
+ expect(hook!.attachments).toEqual([])
+ await act(async () => {
+ await hook!.sendNativeChat('hi')
+ })
+ expect(baseSend).toHaveBeenCalledWith('hi')
+ expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false)
+
+ // Back on the original tab the chip is still pending.
+ update(args)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('keeps isAttaching true when a cancelled pick overlaps a genuine in-flight upload', async () => {
+ // Park a real upload right after onUploadStart (count -> 1, isAttaching true)
+ // by holding its getConnectionId, then fire a cancelled pick. The cancelled
+ // call never incremented, so its finally must not drop the shared counter.
+ let releaseConnection: ((id: string | null) => void) | null = null
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ getActiveWorktreeConnectionId: () =>
+ new Promise((resolve) => {
+ releaseConnection = resolve
+ })
+ })
+ mount(args)
+
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ let firstAttach: Promise | null = null
+ await act(async () => {
+ firstAttach = hook!.attachImage('library')
+ for (let i = 0; i < 50 && !releaseConnection; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(releaseConnection).not.toBeNull()
+ expect(hook!.isAttaching).toBe(true)
+
+ // A concurrent cancelled pick — its finally must leave the counter alone.
+ pick.mockResolvedValue(null)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ expect(hook!.isAttaching).toBe(true)
+
+ // The real upload finishes and clears the flag on its own.
+ await act(async () => {
+ releaseConnection!('conn-1')
+ await firstAttach
+ })
+ expect(hook!.isAttaching).toBe(false)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('clears only the chips that were sent, keeping one attached mid-send', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'), // first attach
+ sendResult(true), // Ctrl+U clear
+ sendResult(true), // first image paste
+ methodNotFound('start'),
+ ok('save', '/tmp/b.png') // second attach, while the send is parked on settle
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ let releaseSettle: (() => void) | null = null
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ baseSend,
+ sleep: () =>
+ new Promise((resolve) => {
+ releaseSettle = resolve
+ })
+ })
+ mount(args)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ let sendPromise: Promise | null = null
+ await act(async () => {
+ sendPromise = hook!.sendNativeChat('hi')
+ // Drain microtasks until the send parks on the settle sleep.
+ for (let i = 0; i < 50 && !releaseSettle; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(releaseSettle).not.toBeNull()
+
+ pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' })
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ await act(async () => {
+ releaseSettle!()
+ await sendPromise
+ })
+
+ // Only the first (sent) image rode along; the mid-send chip survives.
+ expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg'])
+ expect(hook!.attachments.map((a) => a.previewUri)).toEqual(['file:///b.jpg'])
+ })
+
+ it('aborts the send when the active terminal changes during the settle window', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(true) // image paste — into term-1
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ const activeHandleRef = { current: 'term-1' }
+ let releaseSettle: (() => void) | null = null
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ activeHandleRef,
+ baseSend,
+ showToast,
+ sleep: () =>
+ new Promise((resolve) => {
+ releaseSettle = resolve
+ })
+ })
+ mount(args)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ let sendPromise: Promise | null = null
+ await act(async () => {
+ sendPromise = hook!.sendNativeChat('hi')
+ for (let i = 0; i < 50 && !releaseSettle; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(releaseSettle).not.toBeNull()
+ // The user switches tabs while the paste settles: the text + Enter must not
+ // land in term-2 when the images went to term-1.
+ activeHandleRef.current = 'term-2'
+ let accepted = true
+ await act(async () => {
+ releaseSettle!()
+ accepted = await sendPromise!
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(false), // image paste rejected — stale input left in term-1
+ sendResult(true) // healing Ctrl+U before the text-only send
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ await act(async () => {
+ await hook!.sendNativeChat('hi')
+ })
+ expect(baseSend).not.toHaveBeenCalled()
+
+ // The user gives up on the image and removes its chip, then sends plain text.
+ await act(async () => {
+ hook!.removeAttachment('img-1')
+ })
+ expect(hook!.attachments).toEqual([])
+ let accepted = false
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi again')
+ })
+ expect(accepted).toBe(true)
+ const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
+ // Failed attempt's clear + rejected paste, then the healing clear.
+ expect(sendCalls).toHaveLength(3)
+ expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
+ expect(baseSend).toHaveBeenCalledWith('hi again')
+ })
+
+ it('reports a disconnected attach failure via the live connection state', async () => {
+ const client = makeClient([])
+ const showToast = vi.fn()
+ let failUpload: ((error: Error) => void) | null = null
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ showToast,
+ getActiveWorktreeConnectionId: () =>
+ new Promise((_resolve, reject) => {
+ failUpload = reject
+ })
+ })
+ mount(args)
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ let attach: Promise | null = null
+ await act(async () => {
+ attach = hook!.attachImage('library')
+ for (let i = 0; i < 50 && !failUpload; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(failUpload).not.toBeNull()
+ // The connection drops mid-upload, then the in-flight RPC fails. The closure
+ // captured 'connected' at call time — only a live read can toast accurately.
+ update({ ...args, connState: 'connecting' })
+ await act(async () => {
+ failUpload!(new Error('socket closed'))
+ await attach
+ })
+ expect(showToast).toHaveBeenCalledWith('Attach failed (disconnected)', 1500)
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
new file mode 100644
index 000000000..fded5e2f5
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
@@ -0,0 +1,298 @@
+import { useCallback, useRef, useState } from 'react'
+import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import {
+ ImageLibraryPermissionError,
+ pickMobileImage,
+ type MobileImageSource
+} from './mobile-image-source-picker'
+import {
+ uploadMobileNativeChatImage,
+ type PendingNativeChatImage
+} from './mobile-native-chat-image-attachment'
+import {
+ MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
+ pasteMobileNativeChatImagePaths
+} from './mobile-native-chat-image-send'
+
+type CurrentRef = { readonly current: T }
+type ShowToast = (message: string, durationMs?: number) => void
+
+type Args = {
+ readonly client: RpcClient | null
+ readonly activeHandleRef: CurrentRef
+ readonly deviceTokenRef: CurrentRef
+ readonly getActiveWorktreeConnectionId: () => Promise
+ readonly connState: ConnectionState
+ /** Identity of the active composer surface (same key shape as the drafts hook):
+ * chips are scoped to the tab that picked them, so a tab switch cannot ride
+ * one tab's image into another tab's terminal. Null disables attaching. */
+ readonly scopeKey: string | null
+ /** The native-chat input lease is ready — same gate `handleNativeChatSend` uses. */
+ readonly enabled: boolean
+ readonly showToast: ShowToast
+ /** The plain text send (controller.handleNativeChatSend); wrapped so images ride
+ * along. The optional URIs drive the optimistic echo's thumbnails. */
+ readonly baseSend: (text: string, imagePreviewUris?: string[]) => Promise
+ readonly onAttachSuccess?: () => void
+ readonly onError?: () => void
+ // Injected so the settle between image paste and submit is instant in tests.
+ readonly sleep?: (ms: number) => Promise
+}
+
+export type MobileNativeChatImageAttachments = {
+ /** Pending chips for the active scope (tab) only. */
+ readonly attachments: PendingNativeChatImage[]
+ readonly isAttaching: boolean
+ readonly attachImage: (source: MobileImageSource) => Promise
+ readonly removeAttachment: (id: string) => void
+ /** Ride any pending images along with `text`, then submit; clears the sent
+ * chips (and only those) once the send is accepted. */
+ readonly sendNativeChat: (text: string) => Promise
+}
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+const NO_ATTACHMENTS: PendingNativeChatImage[] = []
+
+function withScopeAttachments(
+ byScope: Record,
+ scope: string,
+ next: PendingNativeChatImage[]
+): Record {
+ if (next.length > 0) {
+ return { ...byScope, [scope]: next }
+ }
+ const remaining = { ...byScope }
+ delete remaining[scope]
+ return remaining
+}
+
+const defaultSleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms))
+
+export function useMobileNativeChatImageAttachments({
+ client,
+ activeHandleRef,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ connState,
+ scopeKey,
+ enabled,
+ showToast,
+ baseSend,
+ onAttachSuccess,
+ onError,
+ sleep = defaultSleep
+}: Args): MobileNativeChatImageAttachments {
+ const [attachmentsByScope, setAttachmentsByScope] = useState<
+ Record
+ >({})
+ const [isAttaching, setIsAttaching] = useState(false)
+ const idCounter = useRef(0)
+ // Count in-flight uploads so an overlapping attach can't clear the flag early.
+ const attachingCount = useRef(0)
+ // Live connState for attachImage's catch: the closure's value was already
+ // checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
+ const connStateRef = useRef(connState)
+ connStateRef.current = connState
+ // Terminal whose input line may hold a partial paste from a failed send; the
+ // next send TO THAT terminal must lead with Ctrl+U even if it has no images.
+ const staleInputTerminalRef = useRef(null)
+
+ const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
+
+ const attachImage = useCallback(
+ async (source: MobileImageSource): Promise => {
+ // The chip lands in the scope that initiated the pick, even if the user
+ // switches tabs while the upload is in flight.
+ const scope = scopeKey
+ if (!client || !scope || !activeHandleRef.current || connState !== 'connected') {
+ return
+ }
+ // Only this call's own increment may be undone in `finally`; a cancelled
+ // pick or pre-upload error never ran `onUploadStart`, so decrementing the
+ // shared counter would clear a concurrent upload's in-flight flag early.
+ let started = false
+ try {
+ const uploaded = await uploadMobileNativeChatImage(source, {
+ client,
+ getConnectionId: getActiveWorktreeConnectionId,
+ pickImage: pickMobileImage,
+ onUploadStart: () => {
+ started = true
+ attachingCount.current += 1
+ setIsAttaching(true)
+ }
+ })
+ // Cancelled picker: no error, no toast.
+ if (!uploaded) {
+ return
+ }
+ idCounter.current += 1
+ const chip = { id: `img-${idCounter.current}`, ...uploaded }
+ setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] }))
+ onAttachSuccess?.()
+ } catch (error) {
+ onError?.()
+ if (connStateRef.current !== 'connected') {
+ showToast('Attach failed (disconnected)', 1500)
+ return
+ }
+ if (error instanceof ImageLibraryPermissionError) {
+ showToast('Photo permission denied', 1500)
+ return
+ }
+ if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
+ showToast('Image too large to attach', 1500)
+ return
+ }
+ showToast('Attach failed', 1500)
+ } finally {
+ if (started) {
+ attachingCount.current -= 1
+ if (attachingCount.current <= 0) {
+ attachingCount.current = 0
+ setIsAttaching(false)
+ }
+ }
+ }
+ },
+ [
+ activeHandleRef,
+ client,
+ connState,
+ getActiveWorktreeConnectionId,
+ onAttachSuccess,
+ onError,
+ scopeKey,
+ showToast
+ ]
+ )
+
+ const removeAttachment = useCallback(
+ (id: string): void => {
+ const scope = scopeKey
+ if (!scope) {
+ return
+ }
+ setAttachmentsByScope((prev) =>
+ withScopeAttachments(
+ prev,
+ scope,
+ (prev[scope] ?? []).filter((attachment) => attachment.id !== id)
+ )
+ )
+ },
+ [scopeKey]
+ )
+
+ const sendNativeChat = useCallback(
+ async (text: string): Promise => {
+ const scope = scopeKey
+ const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
+ if (pendingImages.length === 0 || !scope) {
+ // Heal a previously failed paste: a text-only send to that terminal would
+ // otherwise glue the stale image paste onto this message. Best-effort —
+ // on failure the marker stays set and the send proceeds as before.
+ const staleTerminal = staleInputTerminalRef.current
+ if (staleTerminal && staleTerminal === activeHandleRef.current && client) {
+ try {
+ await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: staleTerminal,
+ deviceToken: deviceTokenRef.current,
+ imagePaths: []
+ })
+ staleInputTerminalRef.current = null
+ } catch {
+ // Leave marked for the next attempt.
+ }
+ }
+ return baseSend(text)
+ }
+ const handle = activeHandleRef.current
+ if (!client || !handle || !enabled || connState !== 'connected') {
+ onError?.()
+ // Mirror the text path's failure surface (the base send is never reached).
+ showToast('Message not sent (disconnected)', 1500)
+ return false
+ }
+ try {
+ const pasted = await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: handle,
+ deviceToken: deviceTokenRef.current,
+ imagePaths: pendingImages.map((attachment) => attachment.path)
+ })
+ if (!pasted) {
+ // Keep the chips so the user can retry; the failed paste never submitted.
+ staleInputTerminalRef.current = handle
+ onError?.()
+ showToast('Message not sent', 1500)
+ return false
+ }
+ // The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
+ if (staleInputTerminalRef.current === handle) {
+ staleInputTerminalRef.current = null
+ }
+ // Let the TUI absorb the image paste before the text + Enter follow. The
+ // preview URIs ride along to baseSend so the sent bubble shows the photo
+ // immediately (empty text still submits a bare Enter through baseSend).
+ await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
+ // The paste above targeted `handle`; a tab switch during the settle would
+ // route the text + Enter to a different terminal than the images. Abort —
+ // the chips keep their scope and a retry's Ctrl+U clears the stale paste.
+ if (activeHandleRef.current !== handle) {
+ staleInputTerminalRef.current = handle
+ onError?.()
+ showToast('Message not sent', 1500)
+ return false
+ }
+ const accepted = await baseSend(
+ text,
+ pendingImages.map((attachment) => attachment.previewUri)
+ )
+ if (accepted) {
+ // Drop only what rode along — a chip attached while this send was in
+ // flight keeps waiting for its own send.
+ const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
+ setAttachmentsByScope((prev) =>
+ withScopeAttachments(
+ prev,
+ scope,
+ (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
+ )
+ )
+ }
+ return accepted
+ } catch {
+ // A thrown paste/send (network/RPC) keeps the chips and honors the
+ // Promise contract instead of rejecting. Retry-safe: the next
+ // attempt's leading Ctrl+U clears whatever fraction of the paste landed.
+ staleInputTerminalRef.current = handle
+ onError?.()
+ showToast('Message not sent', 1500)
+ return false
+ }
+ },
+ [
+ activeHandleRef,
+ attachmentsByScope,
+ baseSend,
+ client,
+ connState,
+ deviceTokenRef,
+ enabled,
+ onError,
+ scopeKey,
+ showToast,
+ sleep
+ ]
+ )
+
+ return { attachments, isAttaching, attachImage, removeAttachment, sendNativeChat }
+}
diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts
new file mode 100644
index 000000000..47698b031
--- /dev/null
+++ b/mobile/src/session/use-mobile-session-image-attachments.ts
@@ -0,0 +1,81 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import type { MobileImageSource } from './mobile-image-source-picker'
+import { useMobileImageAttachment } from './use-mobile-image-attachment'
+import {
+ useMobileNativeChatImageAttachments,
+ type MobileNativeChatImageAttachments
+} from './use-mobile-native-chat-image-attachments'
+
+type CurrentRef = { readonly current: T }
+
+type Args = {
+ readonly client: RpcClient | null
+ readonly activeHandle: string | null
+ readonly activeHandleRef: CurrentRef
+ readonly canSend: boolean
+ readonly connState: ConnectionState
+ readonly deviceTokenRef: CurrentRef
+ /** Active-tab identity (same key shape as the drafts hook) — native-chat chips
+ * are scoped per tab so a switch can't ride an image into another terminal. */
+ readonly nativeChatScopeKey: string | null
+ readonly nativeChatInputLeaseReady: boolean
+ readonly getActiveWorktreeConnectionId: () => Promise
+ readonly beforeTerminalSend: (terminal: string) => Promise
+ readonly nativeChatBaseSend: (text: string, images?: string[]) => Promise
+ readonly showToast: (message: string, durationMs?: number) => void
+ readonly onSuccess: () => void
+ readonly onError: () => void
+}
+
+/** A session exposes image attachment on two surfaces that share one upload
+ * pipeline and host wiring: the visible terminal input (immediate bracketed
+ * paste) and the native-chat composer (chips deferred to submit). Owning both
+ * here keeps the already-dense session route to a single wiring point. */
+export function useMobileSessionImageAttachments({
+ client,
+ activeHandle,
+ activeHandleRef,
+ canSend,
+ connState,
+ deviceTokenRef,
+ nativeChatScopeKey,
+ nativeChatInputLeaseReady,
+ getActiveWorktreeConnectionId,
+ beforeTerminalSend,
+ nativeChatBaseSend,
+ showToast,
+ onSuccess,
+ onError
+}: Args): {
+ attachImage: (source: MobileImageSource) => Promise
+ isAttaching: boolean
+ nativeChatImages: MobileNativeChatImageAttachments
+} {
+ const { attachImage, isAttaching } = useMobileImageAttachment({
+ client,
+ activeHandle,
+ canSend,
+ connState,
+ deviceTokenRef,
+ beforeTerminalSend,
+ getActiveWorktreeConnectionId,
+ showToast,
+ onSuccess,
+ onError
+ })
+ const nativeChatImages = useMobileNativeChatImageAttachments({
+ client,
+ activeHandleRef,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ connState,
+ scopeKey: nativeChatScopeKey,
+ enabled: nativeChatInputLeaseReady,
+ showToast,
+ baseSend: nativeChatBaseSend,
+ onAttachSuccess: onSuccess,
+ onError
+ })
+ return { attachImage, isAttaching, nativeChatImages }
+}