fix(mobile): show attached images in native (rich) chat (#10135)

* 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>
This commit is contained in:
Neil 2026-07-23 13:25:50 -07:00 committed by GitHub
parent 84968dfd9e
commit 9500ca7a65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2149 additions and 144 deletions

View File

@ -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 (
<View ref={setMobileSessionRootRef} style={styles.container}>
@ -4586,24 +4594,16 @@ export default function SessionScreen() {
<Pressable
style={[
styles.createButton,
(creating ||
creatingBrowser ||
creatingMarkdown ||
connState !== 'connected') &&
styles.createButtonDisabled
(createTabBusy || connState !== 'connected') && styles.createButtonDisabled
]}
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
}
disabled={createTabBusy || connState !== 'connected'}
onPress={() => {
setCreateError('')
setShowCreateTabDrawer(true)
}}
>
<Text style={styles.createButtonText}>
{creating || creatingBrowser || creatingMarkdown
? 'Creating...'
: 'Create Tab'}
{createTabBusy ? 'Creating...' : 'Create Tab'}
</Text>
</Pressable>
</View>
@ -4720,8 +4720,7 @@ export default function SessionScreen() {
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onAttachImage={() => void attachImage('library')}
isAttaching={isAttaching}
images={nativeChatImages}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}

View File

@ -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 {

View File

@ -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<boolean>
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({
</ScrollView>
</View>
) : null}
{attachments.length > 0 ? (
<ScrollView
horizontal
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}
style={styles.attachmentStrip}
contentContainerStyle={styles.attachmentStripContent}
>
{attachments.map((attachment) => (
<View key={attachment.id} style={styles.attachmentThumb}>
<Image
source={{ uri: attachment.previewUri }}
style={styles.attachmentImage}
resizeMode="cover"
/>
{onRemoveAttachment ? (
<Pressable
accessibilityLabel="Remove image"
style={styles.attachmentRemove}
onPress={() => onRemoveAttachment(attachment.id)}
hitSlop={8}
>
<X size={12} color={colors.textPrimary} strokeWidth={2.6} />
</Pressable>
) : null}
</View>
))}
</ScrollView>
) : null}
<View style={styles.bar}>
{onAttachImage ? (
<Pressable
@ -239,6 +280,45 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
attachmentStrip: {
maxHeight: 76,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
attachmentStripContent: {
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
attachmentThumb: {
width: 60,
height: 60,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
attachmentImage: {
width: '100%',
height: '100%',
borderRadius: radii.button
},
attachmentRemove: {
// Inset inside the thumb: Android drops touches outside the parent's bounds,
// so an overhanging badge would lose part of its tap target.
position: 'absolute',
top: 2,
right: 2,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
bar: {
flexDirection: 'row',
alignItems: 'flex-end',

View File

@ -0,0 +1,87 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
vi.mock('react-native', async () => {
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)
})
})

View File

@ -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 (
<Image
source={{ uri }}
style={styles.imageThumb}
resizeMode="contain"
accessibilityLabel={block.alt ?? 'Attached image'}
/>
)
}
return (
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}

View File

@ -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}

View File

@ -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<boolean>
/** 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}

View File

@ -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)
})
})

View File

@ -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(

View File

@ -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<typeof setTimeout> | 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<string, number>()
const userMessagesByText = new Map<string, Array<{ id: string; index: number }>>()
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<string>()
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
}

View File

@ -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<RpcClient, 'sendRequest'> & {
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)
})
})

View File

@ -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<RpcClient, 'sendRequest'>
readonly getConnectionId: () => Promise<string | null>
// Injected so this module stays free of expo/react-native imports (unit-testable).
readonly pickImage: (source: MobileImageSource) => Promise<PickedMobileImage | null>
// 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<Omit<PendingNativeChatImage, 'id'> | 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 <Image> renders both).
const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
return { path, previewUri }
}

View File

@ -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)
}
})
})

View File

@ -0,0 +1,9 @@
// A URI RN <Image> 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)
}

View File

@ -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<RpcClient, 'sendRequest'> & {
calls: { method: string; params: Record<string, unknown> }[]
} {
const calls: { method: string; params: Record<string, unknown> }[] = []
return {
calls,
sendRequest: vi.fn(async (method: string, params?: unknown) => {
calls.push({ method, params: params as Record<string, unknown> })
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')
})
})

View File

@ -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<RpcClient, 'sendRequest'>
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<boolean> {
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
}

View File

@ -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
}

View File

@ -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,

View File

@ -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')],

View File

@ -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
}))

View File

@ -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
}

View File

@ -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')
})
})

View File

@ -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<SetStateAction<string>>
chatPending: Array<{ id: string; text: string }>
chatPending: MobileNativeChatPendingMessage[]
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
@ -55,7 +58,7 @@ export type MobileNativeChatController = {
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatSend: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
}
/** 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<boolean> => {
async (text: string, images?: string[]): Promise<boolean> => {
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
},
[

View File

@ -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 {

View File

@ -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<typeof setTimeout> | 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<string, number>()
const userMessagesByText = new Map<string, Array<{ id: string; index: number }>>()
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<string>()
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<SetStateAction<string>>
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<Record<string, string>>({})
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

View File

@ -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<RpcClient, 'sendRequest'> & {
calls: { method: string; params: Record<string, unknown> }[]
} {
const calls: { method: string; params: Record<string, unknown> }[] = []
return {
calls,
sendRequest: vi.fn(async (method: string, params?: unknown) => {
calls.push({ method, params: params as Record<string, unknown> })
const response = responses.shift()
if (!response) {
throw new Error(`unexpected request: ${method}`)
}
return response
})
}
}
type HookArgs = Parameters<typeof useMobileNativeChatImageAttachments>[0]
type Hook = ReturnType<typeof useMobileNativeChatImageAttachments>
const SCOPE_A = 'h\0w\0tab-a'
function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): 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<RpcClient, 'sendRequest'> = {
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<string | null>((resolve) => {
releaseConnection = resolve
})
})
mount(args)
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
let firstAttach: Promise<void> | 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<void>((resolve) => {
releaseSettle = resolve
})
})
mount(args)
await act(async () => {
await hook!.attachImage('library')
})
let sendPromise: Promise<boolean> | 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<void>((resolve) => {
releaseSettle = resolve
})
})
mount(args)
await act(async () => {
await hook!.attachImage('library')
})
let sendPromise: Promise<boolean> | 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<string | null>((_resolve, reject) => {
failUpload = reject
})
})
mount(args)
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
let attach: Promise<void> | 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)
})
})

View File

@ -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<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
type Args = {
readonly client: RpcClient | null
readonly activeHandleRef: CurrentRef<string | null>
readonly deviceTokenRef: CurrentRef<string | null>
readonly getActiveWorktreeConnectionId: () => Promise<string | null>
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<boolean>
readonly onAttachSuccess?: () => void
readonly onError?: () => void
// Injected so the settle between image paste and submit is instant in tests.
readonly sleep?: (ms: number) => Promise<void>
}
export type MobileNativeChatImageAttachments = {
/** Pending chips for the active scope (tab) only. */
readonly attachments: PendingNativeChatImage[]
readonly isAttaching: boolean
readonly attachImage: (source: MobileImageSource) => Promise<void>
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<boolean>
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
byScope: Record<string, PendingNativeChatImage[]>,
scope: string,
next: PendingNativeChatImage[]
): Record<string, PendingNativeChatImage[]> {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
const defaultSleep = (ms: number): Promise<void> =>
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<string, PendingNativeChatImage[]>
>({})
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<string | null>(null)
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
// 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<boolean> => {
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<boolean> 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 }
}

View File

@ -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<T> = { readonly current: T }
type Args = {
readonly client: RpcClient | null
readonly activeHandle: string | null
readonly activeHandleRef: CurrentRef<string | null>
readonly canSend: boolean
readonly connState: ConnectionState
readonly deviceTokenRef: CurrentRef<string | null>
/** 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<string | null>
readonly beforeTerminalSend: (terminal: string) => Promise<boolean>
readonly nativeChatBaseSend: (text: string, images?: string[]) => Promise<boolean>
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<void>
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 }
}