mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user