feat(mobile): show attached images as removable thumbnails in native chat

Attaching a picture in mobile native chat gave no visible feedback: the
image uploaded to the host and its path was pasted into the hidden PTY, so
nothing on screen ever showed it was attached.

Defer the TUI paste to send time and hold picked images in composer state,
rendered as a thumbnail tray above the input (spinner while uploading, X
to remove, image-only sends allowed). On send, each ready image's path is
pasted bracketed into the agent TUI right before the message text, matching
the desktop composer-attachment model. The terminal live-input surface keeps
the immediate paste, where the TUI's own input line is the feedback.
This commit is contained in:
Brennan Benson
2026-07-22 12:31:12 -07:00
parent 4d0e3f51ce
commit 32e258d7fc
11 changed files with 816 additions and 87 deletions
+37 -1
View File
@@ -176,6 +176,8 @@ import {
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 { useMobileNativeChatPendingImages } from '../../../../src/session/use-mobile-native-chat-pending-images'
import { useMobileNativeChatImageSend } from '../../../../src/session/use-mobile-native-chat-image-send'
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'
@@ -3637,6 +3639,37 @@ export default function SessionScreen() {
onError: triggerError
})
// Native-chat attachments defer the TUI paste to send time so the composer
// thumbnail stays removable; the terminal surface keeps the immediate paste
// (the TUI's own input line is the visible feedback there).
const {
pendingChatImages,
attachPendingChatImage,
removePendingChatImage,
getReadyChatImages,
consumePendingChatImages
} = useMobileNativeChatPendingImages({
client,
activeHandle,
canAttach: canSend,
connState,
getConnectionId: getActiveWorktreeConnectionId,
showToast,
onSuccess: triggerSelection,
onError: triggerError
})
const sendNativeChatWithImages = useMobileNativeChatImageSend({
clientRef,
activeHandleRef,
deviceTokenRef,
inputLeaseReadyRef: nativeChatInputLeaseReadyRef,
sendText: nativeChatController.handleNativeChatSend,
getReadyImages: getReadyChatImages,
consumeImages: consumePendingChatImages,
onSendError: showNativeChatSendError
})
// Why: refresh canPaste on mount, AppState active, after paste.
useEffect(() => {
let mounted = true
@@ -4687,8 +4720,11 @@ export default function SessionScreen() {
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onAttachImage={() => void attachImage('library')}
onAttachImage={() => void attachPendingChatImage('library')}
isAttaching={isAttaching}
onSend={sendNativeChatWithImages}
pendingImages={pendingChatImages}
onRemovePendingImage={removePendingChatImage}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
+157 -68
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Image,
Pressable,
ScrollView,
StyleSheet,
@@ -8,8 +9,9 @@ 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 type { MobileNativeChatPendingImage } from './use-mobile-native-chat-pending-images'
import {
applyAutocomplete,
detectAutocompleteTrigger,
@@ -30,6 +32,7 @@ const SLASH_COMMANDS = [
]
const NO_FILE_PATHS: string[] = []
const NO_PENDING_IMAGES: MobileNativeChatPendingImage[] = []
type Props = {
/** Controlled composer text — owned by the parent so dictation can write to it. */
@@ -38,6 +41,9 @@ type Props = {
onSend: (text: string) => Promise<boolean>
onAttachImage?: () => void
isAttaching?: boolean
/** Picked-but-unsent images shown as removable thumbnails above the input. */
pendingImages?: readonly MobileNativeChatPendingImage[]
onRemovePendingImage?: (id: string) => void
onMicPress?: () => void
micActive?: boolean
/** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */
@@ -56,6 +62,8 @@ export function MobileNativeChatComposer({
onSend,
onAttachImage,
isAttaching = false,
pendingImages = NO_PENDING_IMAGES,
onRemovePendingImage,
onMicPress,
micActive = false,
dictationMode = 'toggle',
@@ -76,7 +84,15 @@ export function MobileNativeChatComposer({
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching
const readyImageCount = pendingImages.filter((image) => image.status === 'ready').length
const uploadingImages = pendingImages.length > readyImageCount
// Image-only sends are allowed; uploads must finish so send can't race them.
const canSend =
(trimmed.length > 0 || readyImageCount > 0) &&
!disabled &&
!sending &&
!isAttaching &&
!uploadingImages
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo(() => {
@@ -145,72 +161,106 @@ export function MobileNativeChatComposer({
</ScrollView>
</View>
) : null}
<View style={styles.bar}>
{onAttachImage ? (
<Pressable
accessibilityLabel="Attach image"
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
onPress={onAttachImage}
disabled={isAttaching || disabled}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
<View style={styles.composerSurface}>
{pendingImages.length > 0 ? (
<View style={styles.attachmentTray}>
{pendingImages.map((image) => (
<View key={image.id} style={styles.attachmentTile}>
<Image
source={{ uri: image.thumbnailUri }}
style={styles.attachmentImage}
resizeMode="cover"
/>
{image.status === 'uploading' ? (
<View style={styles.attachmentUploadingOverlay}>
<ActivityIndicator size="small" color={colors.textPrimary} />
</View>
) : null}
{onRemovePendingImage ? (
<Pressable
style={({ pressed }) => [styles.attachmentRemove, pressed && styles.pressed]}
onPress={() => onRemovePendingImage(image.id)}
hitSlop={8}
accessibilityLabel="Remove attached image"
>
<X size={14} color={colors.textPrimary} strokeWidth={2.4} />
</Pressable>
) : null}
</View>
))}
</View>
) : null}
<TextInput
style={styles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
selection={pendingSelection ?? undefined}
onSelectionChange={(e) => {
setCursor(e.nativeEvent.selection.end)
setPendingSelection(null)
}}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
editable={!disabled}
textAlignVertical="top"
/>
{onMicPress ? (
<View style={styles.bar}>
{onAttachImage ? (
<Pressable
accessibilityLabel="Attach image"
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
onPress={onAttachImage}
disabled={isAttaching || disabled}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<TextInput
style={styles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
selection={pendingSelection ?? undefined}
onSelectionChange={(e) => {
setCursor(e.nativeEvent.selection.end)
setPendingSelection(null)
}}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
editable={!disabled}
textAlignVertical="top"
/>
{onMicPress ? (
<Pressable
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
disabled={disabled}
>
{micActive ? (
<Square
size={18}
color={colors.statusRed}
strokeWidth={2.4}
fill={colors.statusRed}
/>
) : (
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<Pressable
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
disabled={disabled}
accessibilityLabel="Send message"
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
{micActive ? (
<Square
size={18}
color={colors.statusRed}
strokeWidth={2.4}
fill={colors.statusRed}
/>
) : (
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
)}
<ArrowUp
size={20}
color={canSend ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
) : null}
<Pressable
accessibilityLabel="Send message"
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
<ArrowUp size={20} color={canSend ? colors.bgBase : colors.textMuted} strokeWidth={2.6} />
</Pressable>
</View>
</View>
</View>
)
@@ -239,15 +289,54 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
// Hairline + panel background live on the surface so the attachment tray and
// input bar read as one composer block (no divider between them).
composerSurface: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
attachmentTray: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm
},
attachmentTile: {
width: 88,
height: 88,
borderRadius: radii.card,
overflow: 'hidden',
backgroundColor: colors.bgRaised
},
attachmentImage: {
width: '100%',
height: '100%'
},
attachmentUploadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(17, 17, 17, 0.45)'
},
attachmentRemove: {
position: 'absolute',
top: spacing.xs,
right: spacing.xs,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(17, 17, 17, 0.72)'
},
bar: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
paddingVertical: spacing.sm
},
input: {
flex: 1,
+12 -1
View File
@@ -1,11 +1,17 @@
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
import type { MobileNativeChatPendingImage } from './use-mobile-native-chat-pending-images'
type Props = {
controller: MobileNativeChatController
onAttachImage: () => void
isAttaching: boolean
/** Overrides the controller's plain-text send (e.g. to deliver pending image
* attachments first). Falls back to controller.handleNativeChatSend. */
onSend?: (text: string) => Promise<boolean>
pendingImages?: readonly MobileNativeChatPendingImage[]
onRemovePendingImage?: (id: string) => void
onMicPress: () => void
micActive: boolean
dictationMode: 'toggle' | 'hold'
@@ -21,6 +27,9 @@ export function MobileNativeChatOverlay({
controller,
onAttachImage,
isAttaching,
onSend,
pendingImages,
onRemovePendingImage,
onMicPress,
micActive,
dictationMode,
@@ -54,12 +63,14 @@ export function MobileNativeChatOverlay({
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={controller.handleNativeChatSend}
onSend={onSend ?? controller.handleNativeChatSend}
pending={controller.chatPending}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={onAttachImage}
isAttaching={isAttaching}
pendingImages={pendingImages}
onRemovePendingImage={onRemovePendingImage}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
@@ -23,6 +23,7 @@ import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dism
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import type { MobileNativeChatPendingImage } from './use-mobile-native-chat-pending-images'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
@@ -59,6 +60,8 @@ type Props = {
onComposerTextChange: (text: string) => void
onAttachImage?: () => void
isAttaching?: boolean
pendingImages?: readonly MobileNativeChatPendingImage[]
onRemovePendingImage?: (id: string) => void
onMicPress?: () => void
micActive?: boolean
dictationMode?: 'toggle' | 'hold'
@@ -104,6 +107,8 @@ export function MobileNativeChatView({
onComposerTextChange,
onAttachImage,
isAttaching,
pendingImages,
onRemovePendingImage,
onMicPress,
micActive,
dictationMode,
@@ -404,6 +409,8 @@ export function MobileNativeChatView({
onSend={handleSend}
onAttachImage={onAttachImage}
isAttaching={isAttaching}
pendingImages={pendingImages}
onRemovePendingImage={onRemovePendingImage}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
@@ -25,7 +25,7 @@ describe('pickMobileImage', () => {
})
})
expect(result).toEqual({ base64: 'AAAA' })
expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' })
})
it('throws when photo library permission is denied', async () => {
@@ -59,7 +59,10 @@ describe('pickMobileImage', () => {
})
})
expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64') })
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///doc.png'
})
fetchSpy.mockRestore()
})
@@ -9,6 +9,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 asset URI (file://…) when the picker provides one — lets the composer
// render a thumbnail without holding a second base64 copy in memory.
readonly uri?: string
}
export class ImageLibraryPermissionError extends Error {
@@ -50,7 +53,7 @@ async function pickFromLibrary(
if (!base64) {
return null
}
return { base64 }
return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) }
}
async function pickFromFiles(
@@ -68,7 +71,7 @@ async function pickFromFiles(
if (!asset?.uri) {
return null
}
return { base64: await readUriAsBase64(asset.uri) }
return { base64: await readUriAsBase64(asset.uri), uri: asset.uri }
}
export async function pickMobileImage(
@@ -38,6 +38,21 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Shared toast copy for a failed image attach — used by the immediate
* terminal-paste flow here and the native-chat pending-attachment flow. */
export function mobileImageAttachToastMessage(error: unknown, connected: boolean): string {
if (!connected) {
return 'Attach failed (disconnected)'
}
if (error instanceof ImageLibraryPermissionError) {
return 'Photo permission denied'
}
if (getErrorMessage(error) === 'Clipboard image is too large') {
return 'Image too large to attach'
}
return 'Attach failed'
}
export function useMobileImageAttachment({
client,
activeHandle,
@@ -72,19 +87,7 @@ export function useMobileImageAttachment({
}
} catch (error) {
onError()
if (connState !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (error instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (getErrorMessage(error) === 'Clipboard image is too large') {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
showToast(mobileImageAttachToastMessage(error, connState === 'connected'), 1500)
} finally {
setIsAttaching(false)
}
@@ -0,0 +1,133 @@
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 { useMobileNativeChatImageSend } from './use-mobile-native-chat-image-send'
import type { MobileNativeChatReadyImage } from './use-mobile-native-chat-pending-images'
const sendRequest = vi.fn()
const client = { sendRequest } as unknown as RpcClient
const sendText = vi.fn()
const consumeImages = vi.fn()
const onSendError = vi.fn()
let readyImages: readonly MobileNativeChatReadyImage[] = []
let leaseReady = true
describe('useMobileNativeChatImageSend', () => {
let renderer: ReactTestRenderer | null = null
let send: ((text: string) => Promise<boolean>) | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.clearAllMocks()
readyImages = []
leaseReady = true
sendRequest.mockResolvedValue({ ok: true, result: { send: { accepted: true } } })
sendText.mockResolvedValue(true)
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
send = null
})
function Harness(): null {
send = useMobileNativeChatImageSend({
clientRef: { current: client },
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: 'device-9' },
inputLeaseReadyRef: {
get current() {
return leaseReady
}
},
sendText,
getReadyImages: () => readyImages,
consumeImages,
onSendError
})
return null
}
function render(): void {
act(() => {
renderer = create(createElement(Harness))
})
}
it('passes straight through to the text send when no images are pending', async () => {
render()
await expect(send!('hello')).resolves.toBe(true)
expect(sendText).toHaveBeenCalledWith('hello')
expect(sendRequest).not.toHaveBeenCalled()
})
it('pastes each image bracketed without enter, consumes it, then sends the text', async () => {
readyImages = [
{ id: 'a', hostPath: '/tmp/a.png' },
{ id: 'b', hostPath: '/tmp/b.png' }
]
render()
await expect(send!('what is this?')).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.send', {
terminal: 'term-1',
text: '\x1b[200~/tmp/a.png\x1b[201~',
enter: false,
client: { id: 'device-9', type: 'mobile' }
})
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
terminal: 'term-1',
text: '\x1b[200~/tmp/b.png\x1b[201~',
enter: false,
client: { id: 'device-9', type: 'mobile' }
})
expect(consumeImages.mock.calls).toEqual([[['a']], [['b']]])
expect(sendText).toHaveBeenCalledWith('what is this?')
})
it('submits an image-only send with a bare Enter', async () => {
readyImages = [{ id: 'a', hostPath: '/tmp/a.png' }]
render()
await expect(send!('')).resolves.toBe(true)
expect(sendText).not.toHaveBeenCalled()
expect(sendRequest).toHaveBeenLastCalledWith('terminal.send', {
terminal: 'term-1',
text: '',
enter: true,
client: { id: 'device-9', type: 'mobile' }
})
})
it('stops and keeps undelivered images when a paste is rejected', async () => {
readyImages = [
{ id: 'a', hostPath: '/tmp/a.png' },
{ id: 'b', hostPath: '/tmp/b.png' }
]
sendRequest
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: false } } })
render()
await expect(send!('caption')).resolves.toBe(false)
// Only the delivered image is consumed; 'b' stays attached for retry.
expect(consumeImages.mock.calls).toEqual([[['a']]])
expect(sendText).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Message not sent')
})
it('fails before pasting anything when the input lease is not ready', async () => {
readyImages = [{ id: 'a', hostPath: '/tmp/a.png' }]
leaseReady = false
render()
await expect(send!('caption')).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
expect(consumeImages).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
})
})
@@ -0,0 +1,95 @@
import { useCallback } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import type { MobileNativeChatReadyImage } from './use-mobile-native-chat-pending-images'
type CurrentRef<T> = {
readonly current: T
}
type UseMobileNativeChatImageSendArgs = {
readonly clientRef: CurrentRef<RpcClient | null>
readonly activeHandleRef: CurrentRef<string | null>
readonly deviceTokenRef: CurrentRef<string | null>
readonly inputLeaseReadyRef: CurrentRef<boolean>
/** The plain text send (controller-owned: optimistic bubble + draft clear). */
readonly sendText: (text: string) => Promise<boolean>
readonly getReadyImages: () => readonly MobileNativeChatReadyImage[]
readonly consumeImages: (ids: readonly string[]) => void
readonly onSendError: (message: string) => void
}
/** Wraps the native-chat send so pending image attachments are pasted into the
* agent TUI (bracketed, like a desktop image paste) right before the message
* text — deferring delivery to send time is what lets the composer thumbnails
* be removable. Image-only sends submit with a bare Enter. */
export function useMobileNativeChatImageSend({
clientRef,
activeHandleRef,
deviceTokenRef,
inputLeaseReadyRef,
sendText,
getReadyImages,
consumeImages,
onSendError
}: UseMobileNativeChatImageSendArgs): (text: string) => Promise<boolean> {
return useCallback(
async (text: string): Promise<boolean> => {
const images = getReadyImages()
if (images.length === 0) {
return sendText(text)
}
const client = clientRef.current
const terminal = activeHandleRef.current
if (!client || !terminal || !inputLeaseReadyRef.current) {
onSendError('Message not sent (disconnected)')
return false
}
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
for (const image of images) {
const accepted = await sendMobileNativeChatMessage({
client,
terminal,
text: buildMobileImagePastePayload(image.hostPath),
enter: false,
...(mobileClient ? { mobileClient } : {})
})
if (!accepted) {
onSendError('Message not sent')
return false
}
// Why: consume per image so a mid-sequence failure can't re-paste
// already-delivered paths on retry (they sit in the TUI input now).
consumeImages([image.id])
}
if (text.trim().length > 0) {
return sendText(text)
}
// Image-only send: the paths are in the TUI input; submit with Enter.
const submitted = await sendMobileNativeChatMessage({
client,
terminal,
text: '',
enter: true,
...(mobileClient ? { mobileClient } : {})
})
if (!submitted) {
onSendError('Message not sent')
}
return submitted
},
[
activeHandleRef,
clientRef,
consumeImages,
deviceTokenRef,
getReadyImages,
inputLeaseReadyRef,
onSendError,
sendText
]
)
}
@@ -0,0 +1,171 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const pickMobileImage = vi.fn()
// Fully mocked (no importOriginal): the real module imports expo pickers, whose
// react-native dependency chain doesn't parse under vitest.
vi.mock('./mobile-image-source-picker', () => ({
pickMobileImage: (...args: unknown[]) => pickMobileImage(...args),
ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {}
}))
const saveMobileClipboardImageAsTempFile = vi.fn()
vi.mock('./mobile-clipboard-image', () => ({
saveMobileClipboardImageAsTempFile: (...args: unknown[]) =>
saveMobileClipboardImageAsTempFile(...args),
buildMobileImagePastePayload: (filePath: string) => filePath
}))
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatPendingImages } from './use-mobile-native-chat-pending-images'
type PendingImagesState = ReturnType<typeof useMobileNativeChatPendingImages>
const client = { sendRequest: vi.fn() } as unknown as RpcClient
const showToast = vi.fn()
const onSuccess = vi.fn()
const onError = vi.fn()
describe('useMobileNativeChatPendingImages', () => {
let renderer: ReactTestRenderer | null = null
let state: PendingImagesState | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.clearAllMocks()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({ activeHandle = 't1' }: { activeHandle?: string | null }): null {
state = useMobileNativeChatPendingImages({
client,
activeHandle,
canAttach: true,
connState: 'connected',
getConnectionId: async () => null,
showToast,
onSuccess,
onError
})
return null
}
function render(activeHandle?: string | null): void {
act(() => {
renderer = create(createElement(Harness, { activeHandle }))
})
}
it('adds an uploading entry on pick and flips it ready after the host upload', async () => {
pickMobileImage.mockResolvedValue({ base64: 'AAAA', uri: 'file:///pick.jpg' })
let resolveUpload: (path: string) => void = () => {}
saveMobileClipboardImageAsTempFile.mockReturnValue(
new Promise<string>((resolve) => {
resolveUpload = resolve
})
)
render()
let attachDone: Promise<void> = Promise.resolve()
await act(async () => {
attachDone = state!.attachPendingChatImage('library')
// Let the pick resolve and the uploading entry land.
await Promise.resolve()
await Promise.resolve()
})
expect(state!.pendingChatImages).toEqual([
{ id: 'chat-image-1', thumbnailUri: 'file:///pick.jpg', status: 'uploading', hostPath: null }
])
expect(state!.getReadyChatImages()).toEqual([])
await act(async () => {
resolveUpload('/tmp/orca-img.png')
await attachDone
})
expect(state!.pendingChatImages[0]).toMatchObject({ id: 'chat-image-1', status: 'ready' })
expect(state!.getReadyChatImages()).toEqual([
{ id: 'chat-image-1', hostPath: '/tmp/orca-img.png' }
])
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('falls back to a data URI thumbnail when the picker has no file URI', async () => {
pickMobileImage.mockResolvedValue({ base64: 'AAAA' })
saveMobileClipboardImageAsTempFile.mockResolvedValue('/tmp/a.png')
render()
await act(async () => {
await state!.attachPendingChatImage('library')
})
expect(state!.pendingChatImages[0]?.thumbnailUri).toBe('data:image/png;base64,AAAA')
})
it('adds nothing when the picker is cancelled', async () => {
pickMobileImage.mockResolvedValue(null)
render()
await act(async () => {
await state!.attachPendingChatImage('library')
})
expect(state!.pendingChatImages).toEqual([])
expect(showToast).not.toHaveBeenCalled()
})
it('removes the entry and toasts when the upload fails', async () => {
pickMobileImage.mockResolvedValue({ base64: 'AAAA', uri: 'file:///pick.jpg' })
saveMobileClipboardImageAsTempFile.mockRejectedValue(new Error('upload broke'))
render()
await act(async () => {
await state!.attachPendingChatImage('library')
})
expect(state!.pendingChatImages).toEqual([])
expect(onError).toHaveBeenCalledTimes(1)
expect(showToast).toHaveBeenCalledWith('Attach failed', 1500)
})
it('removes a pending image and consumes delivered ones', async () => {
pickMobileImage
.mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' })
.mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' })
saveMobileClipboardImageAsTempFile
.mockResolvedValueOnce('/tmp/a.png')
.mockResolvedValueOnce('/tmp/b.png')
render()
await act(async () => {
await state!.attachPendingChatImage('library')
await state!.attachPendingChatImage('library')
})
expect(state!.getReadyChatImages()).toHaveLength(2)
act(() => state!.removePendingChatImage('chat-image-1'))
expect(state!.getReadyChatImages()).toEqual([{ id: 'chat-image-2', hostPath: '/tmp/b.png' }])
act(() => state!.consumePendingChatImages(['chat-image-2']))
expect(state!.pendingChatImages).toEqual([])
})
it('drops pending images when the active terminal handle changes', async () => {
pickMobileImage.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
saveMobileClipboardImageAsTempFile.mockResolvedValue('/tmp/a.png')
render('t1')
await act(async () => {
await state!.attachPendingChatImage('library')
})
expect(state!.pendingChatImages).toHaveLength(1)
act(() => {
renderer!.update(createElement(Harness, { activeHandle: 't2' }))
})
expect(state!.pendingChatImages).toEqual([])
expect(state!.getReadyChatImages()).toEqual([])
})
})
@@ -0,0 +1,178 @@
import { useCallback, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image'
import { pickMobileImage, type MobileImageSource } from './mobile-image-source-picker'
import { mobileImageAttachToastMessage } from './use-mobile-image-attachment'
/** A picked-but-unsent image shown as a removable thumbnail in the chat
* composer. Uploads to the host eagerly on pick; the resulting host path is
* pasted into the agent TUI only at send time so removing the thumbnail
* needs no TUI un-paste. */
export type MobileNativeChatPendingImage = {
readonly id: string
readonly thumbnailUri: string
readonly status: 'uploading' | 'ready'
}
export type MobileNativeChatReadyImage = {
readonly id: string
readonly hostPath: string
}
type PendingImageEntry = MobileNativeChatPendingImage & {
readonly hostPath: string | null
}
type UseMobileNativeChatPendingImagesArgs = {
readonly client: RpcClient | null
readonly activeHandle: string | null
readonly canAttach: boolean
readonly connState: ConnectionState
readonly getConnectionId: () => Promise<string | null>
readonly showToast: (message: string, durationMs?: number) => void
readonly onSuccess: () => void
readonly onError: () => void
}
type MobileNativeChatPendingImages = {
readonly pendingChatImages: readonly MobileNativeChatPendingImage[]
readonly attachPendingChatImage: (source: MobileImageSource) => Promise<void>
readonly removePendingChatImage: (id: string) => void
/** Snapshot of fully-uploaded images, in attach order. Read at send time. */
readonly getReadyChatImages: () => readonly MobileNativeChatReadyImage[]
/** Drop entries whose host paths were delivered to the TUI. */
readonly consumePendingChatImages: (ids: readonly string[]) => void
}
export function useMobileNativeChatPendingImages({
client,
activeHandle,
canAttach,
connState,
getConnectionId,
showToast,
onSuccess,
onError
}: UseMobileNativeChatPendingImagesArgs): MobileNativeChatPendingImages {
const [entries, setEntries] = useState<readonly PendingImageEntry[]>([])
const entriesRef = useRef(entries)
const idCounter = useRef(0)
const updateEntries = useCallback(
(updater: (previous: readonly PendingImageEntry[]) => readonly PendingImageEntry[]) => {
setEntries((previous) => {
const next = updater(previous)
entriesRef.current = next
return next
})
},
[]
)
// Why: pending images target the active terminal's agent; a tab/worktree
// switch would silently re-aim them, so drop instead (adjust during render,
// not in an effect, so a send in the same frame can't read stale entries).
const lastHandle = useRef(activeHandle)
if (lastHandle.current !== activeHandle) {
lastHandle.current = activeHandle
if (entriesRef.current.length > 0) {
entriesRef.current = []
setEntries([])
}
}
const attachPendingChatImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
if (!client || !activeHandle || !canAttach) {
return
}
let entryId: string | null = null
try {
const picked = await pickMobileImage(source)
// Cancelled picker: no entry, no toast.
if (!picked) {
return
}
idCounter.current += 1
const id = `chat-image-${idCounter.current}`
entryId = id
updateEntries((previous) => [
...previous,
{
id,
// Why: prefer the picker's file URI so the thumbnail doesn't hold a
// second base64 copy of the image in JS memory.
thumbnailUri: picked.uri ?? `data:image/png;base64,${picked.base64}`,
status: 'uploading',
hostPath: null
}
])
const connectionId = await getConnectionId()
const hostPath = await saveMobileClipboardImageAsTempFile(client, picked.base64, {
connectionId
})
// A handle switch mid-upload cleared the entry; the orphaned host temp
// file is covered by the upload TTL.
updateEntries((previous) =>
previous.map((entry) =>
entry.id === id ? { ...entry, status: 'ready' as const, hostPath } : entry
)
)
onSuccess()
} catch (error) {
if (entryId !== null) {
const failedId = entryId
updateEntries((previous) => previous.filter((entry) => entry.id !== failedId))
}
onError()
showToast(mobileImageAttachToastMessage(error, connState === 'connected'), 1500)
}
},
[
activeHandle,
canAttach,
client,
connState,
getConnectionId,
onError,
onSuccess,
showToast,
updateEntries
]
)
const removePendingChatImage = useCallback(
(id: string) => {
updateEntries((previous) => previous.filter((entry) => entry.id !== id))
},
[updateEntries]
)
const getReadyChatImages = useCallback(
(): readonly MobileNativeChatReadyImage[] =>
entriesRef.current
.filter((entry) => entry.status === 'ready' && entry.hostPath !== null)
.map((entry) => ({ id: entry.id, hostPath: entry.hostPath! })),
[]
)
const consumePendingChatImages = useCallback(
(ids: readonly string[]) => {
if (ids.length === 0) {
return
}
const consumed = new Set(ids)
updateEntries((previous) => previous.filter((entry) => !consumed.has(entry.id)))
},
[updateEntries]
)
return {
pendingChatImages: entries,
attachPendingChatImage,
removePendingChatImage,
getReadyChatImages,
consumePendingChatImages
}
}