From 9500ca7a652ce86f94ac8685aa7d4125bb8be0af Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 23 Jul 2026 13:25:50 -0700
Subject: [PATCH] fix(mobile): show attached images in native (rich) chat
(#10135)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mobile): show attached images in native (rich) chat
Attaching an image in the mobile native chat did nothing visible — it reused
the terminal attach flow, which pastes a bracketed host path into the hidden
terminal, so there was no composer preview and nothing in the transcript.
Give native chat the desktop model instead:
- pick + upload shows a removable thumbnail chip in the composer (no early paste)
- on submit, images ride along: Ctrl+U clear -> bracketed paste(s) -> settle ->
text + Enter (idempotent on retry)
- the optimistic echo carries the local preview URIs and the message renderer
draws image-ref blocks as real thumbnails when the URI is loadable, so the
sent photo appears in the conversation immediately
- image-only echoes reconcile by ordinal against user turns after their tail
(ignores agent replies / paginated history / the 'unknown' ack-loss path)
Terminal chat attach is unchanged (both flows consolidated behind
useMobileSessionImageAttachments). Adds unit coverage for pick+upload, the
ride-along byte order, chip render/remove, and echo reconciliation.
* test(mobile): interactive native-chat image proof (real hooks, click-driven)
Replace the hand-fed component render with an interactive harness that mounts
the real MobileNativeChatComposer/Message + useMobileNativeChatImageAttachments +
drafts under react-native-web and drives the actual flow via clicks. Only the two
OS boundaries are faked: the photo picker and the paired-host RPC socket.
Screenshots (mobile/docs/native-chat-image-attachment/) are produced by real
clicks, not props:
- attach -> real upload pipeline -> chip appears, nothing pasted yet
- send -> real ride-along emits Ctrl+U clear, bracketed image paste, text+Enter
(shown in the live byte trace) and the sent bubble renders the photo thumbnail
* fix(mobile): scope native-chat image attachments by active tab
Images are now scoped to the tab that initiated the pick, so switching tabs
during upload cannot ride an image into another terminal. Chips stay with
their original tab, and only the active scope's images send with text.
Improved error handling with user-facing toast messages for disconnection
and send failures.
* test(mobile): add image attachment tab-scoping and error tests
Add comprehensive test coverage for tab-scoped attachment behavior,
error handling when transport fails or lease is gated, and edge cases
like attaching images during an in-flight send. Extract baseArgs and
update helpers to reduce boilerplate across test cases.
* fix(mobile): show attached images in native rich chat
Images attached in the mobile native (rich) chat now display as:
- Removable composer chips while composing
- Thumbnails in the sent user bubble after sending (desktop parity)
Implements proper image echo reconciliation by distinguishing
image-source marker turns from text echoes, so an image send isn't
cleared by an unrelated text echo. Adds scope isolation to prevent
chips and drafts from leaking between tabs, and detects tab switches
during the image-paste settle window to abort the send.
Fixes Android tap-target positioning for the image removal badge and
clears stale terminal input after failed pastes to avoid gluing
fragments onto the next message.
* rm stubs
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
---
.../app/h/[hostId]/session/[worktreeId].tsx | 31 +-
.../session/MobileNativeChatComposer.test.ts | 59 +-
.../src/session/MobileNativeChatComposer.tsx | 84 ++-
.../session/MobileNativeChatMessage.test.ts | 87 +++
.../src/session/MobileNativeChatMessage.tsx | 16 +-
.../src/session/MobileNativeChatOverlay.tsx | 17 +-
mobile/src/session/MobileNativeChatView.tsx | 14 +-
.../mobile-image-source-picker.test.ts | 15 +-
.../src/session/mobile-image-source-picker.ts | 7 +-
.../mobile-native-chat-draft-reconcile.ts | 103 ++++
...obile-native-chat-image-attachment.test.ts | 101 ++++
.../mobile-native-chat-image-attachment.ts | 46 ++
.../mobile-native-chat-image-preview.test.ts | 29 +
.../mobile-native-chat-image-preview.ts | 9 +
.../mobile-native-chat-image-send.test.ts | 71 +++
.../session/mobile-native-chat-image-send.ts | 54 ++
...le-native-chat-image-transcript-markers.ts | 21 +
.../mobile-native-chat-message-styles.ts | 8 +
.../mobile-native-chat-render-data.test.ts | 49 ++
.../session/mobile-native-chat-render-data.ts | 24 +-
.../session/mobile-native-chat-scope-key.ts | 10 +
.../use-mobile-native-chat-controller.test.ts | 140 +++++
.../use-mobile-native-chat-controller.ts | 15 +-
.../use-mobile-native-chat-drafts.test.ts | 198 +++++++
.../session/use-mobile-native-chat-drafts.ts | 174 +++---
...bile-native-chat-image-attachments.test.ts | 532 ++++++++++++++++++
...se-mobile-native-chat-image-attachments.ts | 298 ++++++++++
.../use-mobile-session-image-attachments.ts | 81 +++
28 files changed, 2149 insertions(+), 144 deletions(-)
create mode 100644 mobile/src/session/MobileNativeChatMessage.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-draft-reconcile.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-attachment.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-attachment.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-preview.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-preview.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-send.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-send.ts
create mode 100644 mobile/src/session/mobile-native-chat-image-transcript-markers.ts
create mode 100644 mobile/src/session/mobile-native-chat-scope-key.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-controller.test.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-image-attachments.ts
create mode 100644 mobile/src/session/use-mobile-session-image-attachments.ts
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 }
+}