From 221d8d3e0440afdb3a5064c81ff73fc7fabdb901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 13:46:26 +0200 Subject: [PATCH 01/12] feat(mobile): show fallback when an image preview fails to decode --- .../attachment-preview-strip.mounted.test.tsx | 63 +++++++++ .../agents/attachment-preview-strip.tsx | 31 +++-- .../image-viewer-modal.mounted.test.tsx | 124 ++++++++++++++++++ .../src/components/image-viewer-modal.tsx | 28 +++- 4 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/components/image-viewer-modal.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx index fbd380b20e..324a120802 100644 --- a/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx +++ b/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx @@ -581,3 +581,66 @@ describe('AttachmentPreviewStrip — tappable unsent chips', () => { renderer.unmount(); }); }); + +describe('AttachmentPreviewStrip — thumbnail decode fallback', () => { + it('shows the AlertCircle fallback while the upload spinner still renders', async () => { + const renderer = await mount([ + makeAttachment({ + kind: 'image', + filename: 'photo.png', + status: 'uploading', + progress: null, + }), + ]); + + const images = nodesByType(renderer.root, 'Image'); + expect(images).toHaveLength(1); + expect(nodesByType(renderer.root, 'ActivityIndicator')).toHaveLength(1); + + const image = images[0]; + if (!image) { + throw new Error('thumbnail Image missing'); + } + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + expect(nodesByType(renderer.root, 'Image')).toHaveLength(0); + expect(nodesByType(renderer.root, 'AlertCircle')).toHaveLength(1); + // The upload overlay is driven by status alone and stays unchanged. + expect(nodesByType(renderer.root, 'ActivityIndicator')).toHaveLength(1); + + renderer.unmount(); + }); + + it('shows the AlertCircle fallback with no spinner for an uploaded image', async () => { + const renderer = await mount([ + makeAttachment({ + kind: 'image', + filename: 'photo.png', + status: 'uploaded', + progress: 1, + }), + ]); + + const images = nodesByType(renderer.root, 'Image'); + expect(images).toHaveLength(1); + expect(nodesByType(renderer.root, 'ActivityIndicator')).toHaveLength(0); + + const image = images[0]; + if (!image) { + throw new Error('thumbnail Image missing'); + } + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + expect(nodesByType(renderer.root, 'Image')).toHaveLength(0); + expect(nodesByType(renderer.root, 'AlertCircle')).toHaveLength(1); + expect(nodesByType(renderer.root, 'ActivityIndicator')).toHaveLength(0); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.tsx index 4c1d0ea2aa..5ee626cbea 100644 --- a/apps/mobile/src/components/agents/attachment-preview-strip.tsx +++ b/apps/mobile/src/components/agents/attachment-preview-strip.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- cohesive chip: thumbnail, status overlays, retry/remove controls, viewer, and text preview share one strip component */ import { useState } from 'react'; import { ActivityIndicator, Modal, Pressable, ScrollView, View } from 'react-native'; import { File } from 'expo-file-system'; @@ -59,6 +60,7 @@ function AttachmentChip({ const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); const [viewerVisible, setViewerVisible] = useState(false); + const [imageFailed, setImageFailed] = useState(false); const [textPreview, setTextPreview] = useState<{ mode: 'markdown' | 'text'; text: string; @@ -131,6 +133,25 @@ function AttachmentChip({ const accessibilityState = isUploading && attachment.progress === null ? { busy: true } : undefined; + const imageThumbnail = imageFailed ? ( + + + + ) : ( + { + setImageFailed(true); + }} + /> + ); + const bodyContent = ( // Visual descendants are excluded from the accessibility tree so // the body stays the single announced element: the nested Texts, @@ -142,15 +163,7 @@ function AttachmentChip({ importantForAccessibility="no-hide-descendants" > {isImage ? ( - + imageThumbnail ) : ( { + const gesture: Record = {}; + gesture.onUpdate = () => gesture; + gesture.onEnd = () => gesture; + gesture.numberOfTaps = () => gesture; + return gesture; +} + +vi.mock('react-native', () => ({ + Modal: 'Modal', + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('@/components/ui/icons', () => ({ + Share: 'Share', + X: 'X', + AlertCircle: 'AlertCircle', +})); +vi.mock('react-native-gesture-handler', () => ({ + Gesture: { + Pinch: makeGesture, + Pan: makeGesture, + Tap: makeGesture, + Race: makeGesture, + Simultaneous: makeGesture, + }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView', +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + useSharedValue: (value: unknown) => ({ value }), + useAnimatedStyle: () => ({}), + withTiming: (value: unknown) => value, +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), +})); +vi.mock('react-native-worklets', () => ({ + scheduleOnRN: vi.fn(), +})); +vi.mock('@/components/ui/image', () => ({ Image: 'Image' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#111827', mutedForeground: '#6b7280' }), +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +async function mountViewer( + props: Partial> +): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create( + createElement(ImageViewerModal, { + visible: true, + uri: 'file:///cache/photo.png', + filename: 'photo.png', + onClose: () => undefined, + ...props, + }) + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('ImageViewerModal mounted', () => { + it('shows the Image unavailable fallback and keeps Share enabled on decode failure', async () => { + const onShare = vi.fn<() => void>(); + const renderer = await mountViewer({ onShare }); + + const images = findByType(renderer.root, 'Image'); + expect(images).toHaveLength(1); + + const image = images[0]; + if (!image) { + throw new Error('viewer Image missing'); + } + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + // The zoomable image is replaced by the fallback. + expect(findByType(renderer.root, 'Image')).toHaveLength(0); + expect(findByType(renderer.root, 'AlertCircle')).toHaveLength(1); + const unavailable = findByType(renderer.root, 'Text').filter( + node => node.props.children === 'Image unavailable' + ); + expect(unavailable).toHaveLength(1); + + // The Share header pressable stays enabled when onShare exists. + const share = findByType(renderer.root, 'Pressable').find( + node => + typeof node.props.accessibilityLabel === 'string' && + node.props.accessibilityLabel.startsWith('Share ') + ); + expect(share).toBeDefined(); + expect(share?.props.disabled).toBe(false); + expect(share?.props.accessibilityState).toEqual({ disabled: false, busy: false }); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/image-viewer-modal.tsx b/apps/mobile/src/components/image-viewer-modal.tsx index 5d459861c7..8cd7810b13 100644 --- a/apps/mobile/src/components/image-viewer-modal.tsx +++ b/apps/mobile/src/components/image-viewer-modal.tsx @@ -1,5 +1,5 @@ -import { Share, X } from '@/components/ui/icons'; -import { useEffect } from 'react'; +import { AlertCircle, Share, X } from '@/components/ui/icons'; +import { useEffect, useState } from 'react'; import { Modal, Pressable, View } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; @@ -35,6 +35,8 @@ export function ImageViewerModal({ const colors = useThemeColors(); const insets = useSafeAreaInsets(); + const [imageError, setImageError] = useState(false); + const scale = useSharedValue(1); const savedScale = useSharedValue(1); const translateX = useSharedValue(0); @@ -63,6 +65,11 @@ export function ImageViewerModal({ } }, [visible, scale, savedScale, translateX, translateY, savedX, savedY]); + // A new image (or a reopen) retries the decode from a clean slate. + useEffect(() => { + setImageError(false); + }, [visible, uri]); + // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pinch(). const pinch = Gesture.Pinch() .onUpdate(event => { @@ -147,13 +154,26 @@ export function ImageViewerModal({ GestureHandlerRootView does not reach a Modal's native view hierarchy. */} - {uri ? ( + {uri && !imageError ? ( - + { + setImageError(true); + }} + /> ) : null} + {uri && imageError ? ( + + + Image unavailable + + ) : null} {shareError ? ( From 7779d8a67886b464d6d63f1fac569961a2d73f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 13:52:51 +0200 Subject: [PATCH 02/12] feat(mobile): classify heic and heif as image attachments --- .../agents/attachment-picker.test.ts | 50 ++++++++++++++++++- .../components/agents/attachment-picker.ts | 31 +++++++++--- .../src/lib/agent-attachments/constants.ts | 2 + .../strip-image-metadata.test.ts | 21 ++++++++ .../lib/agent-attachments/validate.test.ts | 17 +++++++ .../src/lib/agent-attachments/validate.ts | 10 +++- 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/components/agents/attachment-picker.test.ts b/apps/mobile/src/components/agents/attachment-picker.test.ts index 63f34cd25e..31eca09398 100644 --- a/apps/mobile/src/components/agents/attachment-picker.test.ts +++ b/apps/mobile/src/components/agents/attachment-picker.test.ts @@ -5,7 +5,7 @@ import * as SecureStore from 'expo-secure-store'; import * as Sentry from '@sentry/react-native'; import { describe, expect, it, vi } from 'vitest'; -import { pickAgentAttachments } from './attachment-picker'; +import { normalizeImageAsset, pickAgentAttachments } from './attachment-picker'; const reactNativeMock = vi.hoisted(() => ({ alert: vi.fn(), @@ -70,6 +70,54 @@ async function pickWithSheetSelection( return resultPromise; } +describe('normalizeImageAsset', () => { + it('keeps the picker fileName when present', () => { + expect( + normalizeImageAsset({ + uri: 'file:///tmp/IMG_0001.HEIC', + fileName: 'IMG_0001.HEIC', + mimeType: 'application/octet-stream', + }).name + ).toBe('IMG_0001.HEIC'); + }); + + it('treats a whitespace-only fileName as missing and synthesizes from the URI', () => { + expect( + normalizeImageAsset({ + uri: 'file:///tmp/IMG_0001.HEIC', + fileName: ' ', + mimeType: 'application/octet-stream', + }).name + ).toBe('image.heic'); + }); + + it('synthesizes image.heic from the URI extension when fileName is missing', () => { + expect( + normalizeImageAsset({ + uri: 'file:///tmp/IMG_0001.HEIC', + mimeType: 'application/octet-stream', + }).name + ).toBe('image.heic'); + }); + + it('synthesizes image.jpeg from the MIME subtype for an extension-less URI', () => { + expect( + normalizeImageAsset({ + uri: 'file:///tmp/Camera/uuid', + mimeType: 'image/jpeg', + }).name + ).toBe('image.jpeg'); + }); + + it('falls back to image.png when no signal carries the extension', () => { + expect( + normalizeImageAsset({ + uri: 'file:///tmp/Camera/uuid', + }).name + ).toBe('image.png'); + }); +}); + describe('agent attachment picker', () => { it('opens a native action sheet that keeps all sources and the cancel action', () => { const showActionSheet = vi.fn() as unknown as ShowActionSheet & { diff --git a/apps/mobile/src/components/agents/attachment-picker.ts b/apps/mobile/src/components/agents/attachment-picker.ts index 53c9dbd600..0e54f6b806 100644 --- a/apps/mobile/src/components/agents/attachment-picker.ts +++ b/apps/mobile/src/components/agents/attachment-picker.ts @@ -5,6 +5,7 @@ import { type ActionSheetProps } from '@expo/react-native-action-sheet'; import { Alert, Linking } from 'react-native'; import * as Sentry from '@sentry/react-native'; +import { AGENT_ATTACHMENT_EXTENSION_REGEX } from '@/lib/agent-attachments/constants'; import { mimeForExtension, normalizeAttachmentExtension } from '@/lib/agent-attachments/validate'; import { IMAGE_PICKER_OPTIONS, launchImagePicker } from '@/lib/agent-attachments/image-picker'; import { writePickerLaunchContext } from '@/lib/agent-attachments/picker-launch-context'; @@ -23,14 +24,30 @@ export function normalizeImageAsset(asset: { mimeType?: string | null; fileSize?: number | null; }): AgentAttachmentCandidate { - // Image picker cannot return a filename with an arbitrary extension; - // synthesize one from the picker's MIME so `normalizeAttachmentExtension` - // can resolve a known key. The actual byte size is re-measured by the - // upload hook via `getInfoAsync`; `size` here is informational. - const fallbackName = `image.${(asset.mimeType ?? 'image/png').split('/')[1] ?? 'png'}`; - const name = asset.fileName ?? fallbackName; + // Keep the picker's filename when it is non-empty after trimming. + const fileName = asset.fileName?.trim(); + if (fileName) { + return { + name: fileName, + uri: asset.uri, + mimeType: asset.mimeType ?? undefined, + size: asset.fileSize ?? undefined, + }; + } + + // The image picker can omit the filename — camera HEIC assets report + // `application/octet-stream` with no name. Synthesize `image.` from + // the URI extension, then the MIME subtype, then fall back to `image.png`. + // The upload hook re-measures size via `getInfoAsync`; `size` here is + // informational. + const uriExtension = asset.uri.split('.').pop()?.toLowerCase(); + const mimeSubtype = asset.mimeType?.split('/')[1]?.toLowerCase(); + const extension = + (uriExtension && AGENT_ATTACHMENT_EXTENSION_REGEX.test(uriExtension) ? uriExtension : null) ?? + (mimeSubtype && AGENT_ATTACHMENT_EXTENSION_REGEX.test(mimeSubtype) ? mimeSubtype : null) ?? + 'png'; return { - name, + name: `image.${extension}`, uri: asset.uri, mimeType: asset.mimeType ?? undefined, size: asset.fileSize ?? undefined, diff --git a/apps/mobile/src/lib/agent-attachments/constants.ts b/apps/mobile/src/lib/agent-attachments/constants.ts index a826582bee..cd8ea19b3d 100644 --- a/apps/mobile/src/lib/agent-attachments/constants.ts +++ b/apps/mobile/src/lib/agent-attachments/constants.ts @@ -48,6 +48,8 @@ export const AGENT_ATTACHMENT_MIME_BY_EXTENSION = { jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', + heic: 'image/heic', + heif: 'image/heif', // Documents pdf: 'application/pdf', // Text-ish source — server treats all of these as text/plain diff --git a/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts b/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts index 3fff035ac7..10975591e2 100644 --- a/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts +++ b/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts @@ -26,6 +26,11 @@ describe('strippedExtension', () => { expect(strippedExtension('jpg')).toBe('jpg'); expect(strippedExtension('gif')).toBe('jpg'); }); + + it('re-encodes heic and heif to jpg', () => { + expect(strippedExtension('heic')).toBe('jpg'); + expect(strippedExtension('heif')).toBe('jpg'); + }); }); describe('stripImageMetadata', () => { @@ -46,6 +51,22 @@ describe('stripImageMetadata', () => { expect(result).toBe('file:///cache/stripped.png'); }); + it('re-encodes heic and heif with the JPEG save format', async () => { + mocks.manipulateAsync.mockResolvedValue({ uri: 'file:///cache/stripped.jpg' }); + + await stripImageMetadata('file:///cache/original.heic', 'heic'); + expect(mocks.manipulateAsync).toHaveBeenCalledWith('file:///cache/original.heic', [], { + compress: 1, + format: 'jpeg', + }); + + await stripImageMetadata('file:///cache/original.heif', 'heif'); + expect(mocks.manipulateAsync).toHaveBeenCalledWith('file:///cache/original.heif', [], { + compress: 1, + format: 'jpeg', + }); + }); + it('falls back to the original URI on failure and reports to Sentry', async () => { mocks.manipulateAsync.mockRejectedValue(new Error('re-encode failed')); diff --git a/apps/mobile/src/lib/agent-attachments/validate.test.ts b/apps/mobile/src/lib/agent-attachments/validate.test.ts index dd8e7f5cac..4fcadec0f4 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.test.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.test.ts @@ -218,6 +218,21 @@ describe('classifyAttachment', () => { }); }); + it('classifies HEIC and HEIF names as images', () => { + expect(classifyAttachment({ name: 'photo.HEIC', size: 10 })).toEqual({ + ok: true, + kind: 'image', + extension: 'heic', + size: 10, + }); + expect(classifyAttachment({ name: 'photo.heif', size: 10 })).toEqual({ + ok: true, + kind: 'image', + extension: 'heif', + size: 10, + }); + }); + it('accepts an extension outside the image/document allow-list as a generic binary', () => { expect(classifyAttachment({ name: 'archive.zip', size: 10 })).toEqual({ ok: true, @@ -316,6 +331,8 @@ describe('mimeForExtension (cross-surface parity)', () => { expect(mimeForExtension('jpeg')).toBe('image/jpeg'); expect(mimeForExtension('webp')).toBe('image/webp'); expect(mimeForExtension('gif')).toBe('image/gif'); + expect(mimeForExtension('heic')).toBe('image/heic'); + expect(mimeForExtension('heif')).toBe('image/heif'); expect(mimeForExtension('pdf')).toBe('application/pdf'); expect(mimeForExtension('txt')).toBe('text/plain'); expect(mimeForExtension('md')).toBe('text/plain'); diff --git a/apps/mobile/src/lib/agent-attachments/validate.ts b/apps/mobile/src/lib/agent-attachments/validate.ts index 7e2fefeb5a..cc67fe7d04 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.ts @@ -11,7 +11,15 @@ import { } from './constants'; import { truncateUtf8, utf8ByteLength } from '../utf8-utils'; -const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']); +const IMAGE_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'heic', + 'heif', +]); /** * Normalize a candidate's filename extension. From 073886724011cca4c233122c8c4a3098f4b32269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 13:52:52 +0200 Subject: [PATCH 03/12] feat(mobile): share session-page image and markdown previews --- .../file-part-renderer.mounted.test.tsx | 242 ++++++++++++++++++ .../components/agents/file-part-renderer.tsx | 80 ++++-- .../components/sheet-header.mounted.test.tsx | 89 +++++++ apps/mobile/src/components/sheet-header.tsx | 19 ++ 4 files changed, 415 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/components/sheet-header.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 9ef5ef3fed..ae54868034 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -5,6 +5,8 @@ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShareRemoteFileError } from '@/lib/share-remote-file'; + import { FilePartRenderer } from './file-part-renderer'; import { __resetFilePartCacheForTests, cacheFilePart } from './file-part-cache'; @@ -594,4 +596,244 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + + it('passes a share action to the image viewer and shares an http(s) URL via shareRemoteFile', async () => { + cacheFilePart('part-1', { url: 'https://x/a.png', mime: 'image/png', filename: 'shot.png' }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Open shot.png full screen'))); + + const viewers = findByType(root, 'ImageViewerModal'); + expect(viewers).toHaveLength(1); + expect(viewers[0]?.props.onShare).toBeTypeOf('function'); + expect(viewers[0]?.props.sharing).toBe(false); + + await act(async () => { + await Promise.resolve(); + (first(viewers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(shareRemoteFileMock.shareRemoteFile).toHaveBeenCalledTimes(1); + expect(shareRemoteFileMock.shareRemoteFile).toHaveBeenCalledWith({ + url: 'https://x/a.png', + cacheDirectoryName: 'session-file-parts', + cacheKey: 'part-1', + filename: 'shot.png', + }); + + await unmount(renderer); + }); + + it('shares a captured data: image through the viewer via shareLocalFile', async () => { + cacheFilePart('part-1', { + url: 'data:image/png;base64,QUJD', + mime: 'image/png', + filename: 'shot.png', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Open shot.png full screen'))); + + const viewers = findByType(root, 'ImageViewerModal'); + await act(async () => { + await Promise.resolve(); + (first(viewers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(shareRemoteFileMock.shareLocalFile).toHaveBeenCalledTimes(1); + expect(shareRemoteFileMock.shareLocalFile).toHaveBeenCalledWith( + 'file:///cache/session-file-parts/part-1-shot.png', + { mimeType: 'image/png' } + ); + + await unmount(renderer); + }); + + it('shares a captured data: markdown through the preview sheet via shareLocalFile', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Hello'); + cacheFilePart('part-1', { + url: 'data:text/markdown;base64,QUJD', + mime: 'text/markdown', + filename: 'readme.md', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Preview readme.md'))); + await flushAsync(); + + const headers = findByType(root, 'SheetHeader'); + expect(headers).toHaveLength(1); + expect(headers[0]?.props.onShare).toBeTypeOf('function'); + + await act(async () => { + await Promise.resolve(); + (first(headers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(shareRemoteFileMock.shareLocalFile).toHaveBeenCalledTimes(1); + expect(shareRemoteFileMock.shareLocalFile).toHaveBeenCalledWith( + 'file:///cache/session-file-parts/part-1-readme.md', + { mimeType: 'text/markdown' } + ); + + await unmount(renderer); + }); + + it('shares an http(s) markdown through the preview sheet via shareRemoteFile', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Hello'); + cacheFilePart('part-1', { + url: 'https://x/readme.md', + mime: 'text/markdown', + filename: 'readme.md', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Preview readme.md'))); + await flushAsync(); + + const headers = findByType(root, 'SheetHeader'); + await act(async () => { + await Promise.resolve(); + (first(headers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(shareRemoteFileMock.shareRemoteFile).toHaveBeenCalledTimes(1); + expect(shareRemoteFileMock.shareRemoteFile).toHaveBeenCalledWith({ + url: 'https://x/readme.md', + cacheDirectoryName: 'session-file-parts', + cacheKey: 'part-1', + filename: 'readme.md', + }); + + await unmount(renderer); + }); + + it('renders share failures inline in the image viewer instead of toasting', async () => { + cacheFilePart('part-1', { url: 'https://x/a.png', mime: 'image/png', filename: 'shot.png' }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Open shot.png full screen'))); + + shareRemoteFileMock.shareRemoteFile.mockRejectedValueOnce(new Error('boom')); + shareRemoteFileMock.getShareRemoteFileReason.mockReturnValueOnce(null); + + const viewers = findByType(root, 'ImageViewerModal'); + await act(async () => { + await Promise.resolve(); + (first(viewers).props.onShare as () => void)(); + }); + await flushAsync(); + + const updated = findByType(root, 'ImageViewerModal'); + expect(updated[0]?.props.shareError).not.toBeNull(); + expect(toastMock.error).not.toHaveBeenCalled(); + + await unmount(renderer); + }); + + it('renders share failures inline in the Markdown preview instead of toasting', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Hello'); + cacheFilePart('part-1', { + url: 'data:text/markdown;base64,QUJD', + mime: 'text/markdown', + filename: 'readme.md', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Preview readme.md'))); + await flushAsync(); + + shareRemoteFileMock.shareLocalFile.mockRejectedValueOnce(new Error('boom')); + shareRemoteFileMock.getShareRemoteFileReason.mockReturnValueOnce(null); + + const headers = findByType(root, 'SheetHeader'); + await act(async () => { + await Promise.resolve(); + (first(headers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(texts(root)).toContain('Share failed'); + expect(toastMock.error).not.toHaveBeenCalled(); + + await unmount(renderer); + }); + + it('renders the retryable share error inline in the image viewer', async () => { + cacheFilePart('part-1', { url: 'https://x/a.png', mime: 'image/png', filename: 'shot.png' }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Open shot.png full screen'))); + + shareRemoteFileMock.shareRemoteFile.mockRejectedValueOnce(new ShareRemoteFileError('boom')); + + const viewers = findByType(root, 'ImageViewerModal'); + await act(async () => { + await Promise.resolve(); + (first(viewers).props.onShare as () => void)(); + }); + await flushAsync(); + + const updated = findByType(root, 'ImageViewerModal'); + expect(updated[0]?.props.shareError).toBe('Failed to share file. Please try again.'); + expect(toastMock.error).not.toHaveBeenCalled(); + + await unmount(renderer); + }); + + it('renders the non-retryable share error inline in the Markdown preview', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Hello'); + cacheFilePart('part-1', { + url: 'data:text/markdown;base64,QUJD', + mime: 'text/markdown', + filename: 'readme.md', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Preview readme.md'))); + await flushAsync(); + + shareRemoteFileMock.shareLocalFile.mockRejectedValueOnce(new Error('boom')); + shareRemoteFileMock.getShareRemoteFileReason.mockReturnValueOnce('sharing-unavailable'); + + const headers = findByType(root, 'SheetHeader'); + await act(async () => { + await Promise.resolve(); + (first(headers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(texts(root)).toContain('File sharing is not available on this device.'); + expect(toastMock.error).not.toHaveBeenCalled(); + + await unmount(renderer); + }); }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 4f300e2cf8..3e08d53a43 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -118,35 +118,61 @@ export function FilePartRenderer({ part }: Readonly) { const [imageFailed, setImageFailed] = useState(false); const [preview, setPreview] = useState(null); const [sharing, setSharing] = useState(false); + const [shareError, setShareError] = useState(null); async function handleShare() { if (!url) { return; } setSharing(true); + setShareError(null); try { await shareFilePart(url, part); } catch (error: unknown) { const reason = getShareRemoteFileReason(error); + let message = 'Share failed'; if (reason === 'sharing-unavailable') { - toast.error('File sharing is not available on this device.'); + message = 'File sharing is not available on this device.'; } else if (error instanceof ShareRemoteFileError) { - toast.error('Failed to share file. Please try again.'); + message = 'Failed to share file. Please try again.'; + } + if (viewerVisible || preview) { + setShareError(message); } else { - toast.error('Share failed'); + toast.error(message); } } finally { setSharing(false); } } + function openViewer() { + setShareError(null); + setViewerVisible(true); + } + + function closeViewer() { + setShareError(null); + setViewerVisible(false); + } + + function openPreview(mode: PreviewMode) { + setShareError(null); + setPreview(mode); + } + + function closePreview() { + setShareError(null); + setPreview(null); + } + function handleChipTap() { if (!url) { toast.error('Preview unavailable'); return; } if (kind === 'markdown') { - setPreview('markdown'); + openPreview('markdown'); return; } showActionSheetWithOptions( @@ -159,7 +185,7 @@ export function FilePartRenderer({ part }: Readonly) { return; } if (index === 0) { - setPreview('text'); + openPreview('text'); } else if (index === 1) { void handleShare(); } @@ -187,9 +213,7 @@ export function FilePartRenderer({ part }: Readonly) { return ( <> { - setViewerVisible(true); - }} + onPress={openViewer} className="my-1 overflow-hidden rounded-lg active:opacity-80" accessibilityRole="button" accessibilityLabel={getFilePartAccessibilityLabel('image', part.filename)} @@ -214,9 +238,12 @@ export function FilePartRenderer({ part }: Readonly) { visible={viewerVisible} uri={url} filename={part.filename ?? 'File'} - onClose={() => { - setViewerVisible(false); + onShare={() => { + void handleShare(); }} + sharing={sharing} + shareError={shareError} + onClose={closeViewer} /> )} @@ -250,9 +277,12 @@ export function FilePartRenderer({ part }: Readonly) { mode={preview} url={url} part={part} - onClose={() => { - setPreview(null); + onShare={() => { + void handleShare(); }} + sharing={sharing} + shareError={shareError} + onClose={closePreview} /> ) : null} @@ -264,9 +294,20 @@ type FilePreviewModalProps = { url: string; part: FilePart; onClose: () => void; + onShare?: () => void; + sharing?: boolean; + shareError?: string | null; }; -function FilePreviewModal({ mode, url, part, onClose }: Readonly) { +function FilePreviewModal({ + mode, + url, + part, + onClose, + onShare, + sharing = false, + shareError = null, +}: Readonly) { const { id, mime, filename } = part; const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [text, setText] = useState(''); @@ -329,8 +370,17 @@ function FilePreviewModal({ mode, url, part, onClose }: Readonly - - {renderBody()} + + + {shareError ? {shareError} : null} + {renderBody()} + ); diff --git a/apps/mobile/src/components/sheet-header.mounted.test.tsx b/apps/mobile/src/components/sheet-header.mounted.test.tsx new file mode 100644 index 0000000000..7764d1ad31 --- /dev/null +++ b/apps/mobile/src/components/sheet-header.mounted.test.tsx @@ -0,0 +1,89 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/ui/accessible-status.mounted.test.tsx) */ +import { type ComponentProps, createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { SheetHeader } from './sheet-header'; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ Share: 'Share' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#111827' }), +})); + +async function mount( + props: ComponentProps +): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create(createElement(SheetHeader, props)); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function pressablesByLabel( + root: TestRenderer.ReactTestInstance, + label: string +): TestRenderer.ReactTestInstance[] { + return root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Pressable' && + node.props.accessibilityLabel === label + ); +} + +describe('SheetHeader share action', () => { + it('renders a Share pressable in the left slot when onShare is provided', async () => { + const renderer = await mount({ + title: 'report.pdf', + onDone: () => undefined, + onShare: () => undefined, + }); + + const shares = pressablesByLabel(renderer.root, 'Share report.pdf'); + expect(shares).toHaveLength(1); + expect(shares[0]?.props.accessibilityRole).toBe('button'); + expect(shares[0]?.props.disabled).toBe(false); + + renderer.unmount(); + }); + + it('disables the Share pressable while sharing', async () => { + const renderer = await mount({ + title: 'report.pdf', + onDone: () => undefined, + onShare: () => undefined, + sharing: true, + }); + + const shares = pressablesByLabel(renderer.root, 'Share report.pdf'); + expect(shares).toHaveLength(1); + expect(shares[0]?.props.disabled).toBe(true); + + renderer.unmount(); + }); + + it('renders no Share pressable without onShare and keeps the Done button', async () => { + const renderer = await mount({ + title: 'report.pdf', + onDone: () => undefined, + }); + + expect(pressablesByLabel(renderer.root, 'Share report.pdf')).toHaveLength(0); + expect(pressablesByLabel(renderer.root, 'Done')).toHaveLength(1); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/sheet-header.tsx b/apps/mobile/src/components/sheet-header.tsx index 00e57c55d3..3f879a2cc0 100644 --- a/apps/mobile/src/components/sheet-header.tsx +++ b/apps/mobile/src/components/sheet-header.tsx @@ -1,18 +1,25 @@ import { Pressable, View } from 'react-native'; +import { Share } from '@/components/ui/icons'; import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export function SheetHeader({ title, onDone, onCancel, doneLabel = 'Done', + onShare, + sharing = false, }: { title: string; onDone: () => void; onCancel?: () => void; doneLabel?: string; + onShare?: () => void; + sharing?: boolean; }) { + const colors = useThemeColors(); return ( // collapsable={false}: react-native-screens lays out a formSheet's scroll // view by finding the header at the screen content's subview index 0 — a @@ -28,6 +35,18 @@ export function SheetHeader({ {title} + {onShare !== undefined ? ( + + + + ) : null} {onCancel ? ( Date: Fri, 21 Aug 2026 14:18:52 +0200 Subject: [PATCH 04/12] fix(mobile): use valid ShareRemoteFileReason in share test --- .../src/components/agents/file-part-renderer.mounted.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index ae54868034..1419e3389d 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -790,7 +790,9 @@ describe('FilePartRenderer mounted', () => { await press(first(pressableByLabel(root, 'Open shot.png full screen'))); - shareRemoteFileMock.shareRemoteFile.mockRejectedValueOnce(new ShareRemoteFileError('boom')); + shareRemoteFileMock.shareRemoteFile.mockRejectedValueOnce( + new ShareRemoteFileError('download-failed') + ); const viewers = findByType(root, 'ImageViewerModal'); await act(async () => { From 9c3aef8a0034d96d57603573519bfb9490ca12bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 14:25:30 +0200 Subject: [PATCH 05/12] feat(mobile): upload image attachments at selection time Start the upload for every image addition in addCandidates so the chip reaches uploaded before send. Disable the new-session Start button while an image uploads on the remote target branch too. --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 1 + .../use-agent-attachment-upload.test.ts | 95 ++++++++++++++++++- .../use-agent-attachment-upload.ts | 11 ++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index ab8bdb4a44..e7af211439 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -415,6 +415,7 @@ function NewSessionScreenBody() { ? remoteSpawn.isSpawningRemote || isSubmitting || attachments.hasFailedAttachments || + attachments.isUploading || modelView.isSelectionUnavailable || instanceCatalog.isLoading : resolveNewSessionStartDisabled({ diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts index eee4dea506..cf3a998015 100644 --- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts @@ -3,6 +3,7 @@ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as ImageManipulator from 'expo-image-manipulator'; import { AGENT_ATTACHMENT_MAX_BYTES } from './constants'; import { @@ -406,7 +407,7 @@ async function settle(): Promise { await Promise.resolve(); } -describe('addCandidates performs no upload', () => { +describe('addCandidates defers document uploads', () => { beforeEach(() => { hoisted.uploadOne.mockReset(); hoisted.measureLocalSize.mockReset(); @@ -483,6 +484,98 @@ describe('uploadPending', () => { }); }); +describe('selection-time image upload (Step 2)', () => { + beforeEach(() => { + hoisted.uploadOne.mockReset(); + hoisted.announceForA11y.mockReset(); + hoisted.announcingToastError.mockReset(); + hoisted.measureLocalSize.mockReset(); + hoisted.measureLocalSize.mockResolvedValue(1024); + vi.mocked(ImageManipulator.manipulateAsync).mockReset(); + vi.mocked(ImageManipulator.manipulateAsync).mockResolvedValue({ + uri: 'file:///cache/stripped.jpg', + width: 100, + height: 100, + }); + resolveUpload = undefined; + rejectUpload = undefined; + const controlled = new Promise<{ key: string }>((resolve, reject) => { + resolveUpload = resolve; + rejectUpload = reject; + }); + hoisted.uploadOne.mockReturnValue(controlled); + }); + + it('starts the upload for an image at selection time and flips the chip to uploaded', async () => { + const renderer = await mountHook(); + await act(async () => { + await hookApi().addCandidates([ + { name: 'IMG_0001.HEIC', uri: 'file:///cache/IMG_0001.HEIC' }, + ]); + }); + + expect(hoisted.uploadOne).toHaveBeenCalledTimes(1); + const chip = hookApi().attachments[0]; + expect(chip?.status).toBe('uploading'); + // The strip mock re-encodes HEIC to JPEG, proving the Step 1 pipeline. + expect(chip?.extension).toBe('jpg'); + expect(chip?.mimeType).toBe('image/jpeg'); + + await act(async () => { + resolveUpload?.({ key: 'org/2026/08/uuid/img.jpg' }); + await settle(); + }); + + const uploaded = hookApi().attachments[0]; + expect(uploaded?.status).toBe('uploaded'); + expect(uploaded?.progress).toBe(1); + expect(uploaded?.remoteFilename).toBe('img.jpg'); + renderer.unmount(); + }); + + it('marks a selection-time upload rejection as a retryable error', async () => { + const renderer = await mountHook(); + await act(async () => { + await hookApi().addCandidates([ + { name: 'IMG_0001.HEIC', uri: 'file:///cache/IMG_0001.HEIC' }, + ]); + }); + + await act(async () => { + rejectUpload?.(new TypeError('Network request failed')); + await settle(); + }); + + const chip = hookApi().attachments[0]; + expect(chip?.status).toBe('error'); + expect(chip?.terminal).toBe(false); + renderer.unmount(); + }); + + it('returns ok from uploadPending after the image pre-uploaded without a second uploadOne call', async () => { + const renderer = await mountHook(); + await act(async () => { + await hookApi().addCandidates([ + { name: 'IMG_0001.HEIC', uri: 'file:///cache/IMG_0001.HEIC' }, + ]); + }); + await act(async () => { + resolveUpload?.({ key: 'org/2026/08/uuid/img.jpg' }); + await settle(); + }); + expect(hoisted.uploadOne).toHaveBeenCalledTimes(1); + + let result: Awaited> | undefined = undefined; + await act(async () => { + result = await hookApi().uploadPending(); + }); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(hoisted.uploadOne).toHaveBeenCalledTimes(1); + renderer.unmount(); + }); +}); + describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () => { beforeEach(() => { hoisted.uploadOne.mockReset(); diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts index 1e95cf3a83..2e66a8c0fe 100644 --- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts @@ -387,8 +387,17 @@ export function useAgentAttachmentUpload( for (const addition of additions) { liveIdsRef.current.add(addition.id); } + // Step 2: images upload at selection time. Documents stay deferred to + // send (`uploadPending`). The upload is fire-and-forget: the chip flips + // to `uploading` synchronously in `startUpload`, and success/error + // states already exist in that path. + for (const addition of additions) { + if (addition.kind === 'image') { + void startUpload(addition, pathRef.current); + } + } }, - [attachments.length, commitAttachments] + [attachments.length, commitAttachments, startUpload] ); const removeAttachment = useCallback( From b491040725f13bf1773f08a38309ffe3aef5adbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 14:46:24 +0200 Subject: [PATCH 06/12] test(mobile): cover empty-file share from markdown preview --- .../file-part-renderer.mounted.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 1419e3389d..d28bf6199e 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -523,6 +523,42 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + it('shares the source file from the header Share on an empty markdown preview', async () => { + expoFileSystemMock.fileText.mockResolvedValue(''); + cacheFilePart('part-1', { + url: 'data:text/markdown;base64,', + mime: 'text/markdown', + filename: 'readme.md', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Preview readme.md'))); + await flushAsync(); + + expect(texts(root)).toContain('This file is empty.'); + + const headers = findByType(root, 'SheetHeader'); + expect(headers).toHaveLength(1); + expect(headers[0]?.props.onShare).toBeTypeOf('function'); + + await act(async () => { + await Promise.resolve(); + (first(headers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(shareRemoteFileMock.shareLocalFile).toHaveBeenCalledTimes(1); + expect(shareRemoteFileMock.shareLocalFile).toHaveBeenCalledWith( + 'file:///cache/session-file-parts/part-1-readme.md', + { mimeType: 'text/markdown' } + ); + + await unmount(renderer); + }); + it('shows an error and retry when the text fails to load', async () => { expoFileSystemMock.fileText.mockRejectedValue(new Error('boom')); cacheFilePart('part-1', { From 3a3e7d82ed431bd0b901df7d29547ad3337c0132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 17:32:34 +0200 Subject: [PATCH 07/12] refactor(mobile): merge attachment add and upload loops --- .../src/lib/agent-attachments/use-agent-attachment-upload.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts index 2e66a8c0fe..da58500cc9 100644 --- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts @@ -384,14 +384,12 @@ export function useAgentAttachmentUpload( return; } commitAttachments(current => [...current, ...additions]); - for (const addition of additions) { - liveIdsRef.current.add(addition.id); - } // Step 2: images upload at selection time. Documents stay deferred to // send (`uploadPending`). The upload is fire-and-forget: the chip flips // to `uploading` synchronously in `startUpload`, and success/error // states already exist in that path. for (const addition of additions) { + liveIdsRef.current.add(addition.id); if (addition.kind === 'image') { void startUpload(addition, pathRef.current); } From 17e3cfead5c38fbe4f9bf06f60c06bdc34f7138d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 19:50:37 +0200 Subject: [PATCH 08/12] fix(mobile): surface share failures that land after the viewer closes The share handler read viewerVisible and preview from its render closure after the share await, so a failure that landed once the viewer or sheet had closed set state on an unmounted surface and was silently dropped. Track the current visibility in refs and read those in the catch branch so the failure surfaces as a toast instead. --- .../file-part-renderer.mounted.test.tsx | 41 +++++++++++++++++++ .../components/agents/file-part-renderer.tsx | 15 ++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index e85aa671e1..1088600fa3 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -808,6 +808,47 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + it('toasts a share failure that lands after the viewer closed', async () => { + cacheFilePart('part-1', { url: 'https://x/a.png', mime: 'image/png', filename: 'shot.png' }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Open shot.png full screen'))); + + const shareHolder: { reject?: (error: Error) => void } = {}; + shareRemoteFileMock.shareRemoteFile.mockReturnValueOnce( + new Promise((_resolve, reject) => { + shareHolder.reject = reject; + }) + ); + shareRemoteFileMock.getShareRemoteFileReason.mockReturnValueOnce(null); + + const viewers = findByType(root, 'ImageViewerModal'); + await act(async () => { + await Promise.resolve(); + (first(viewers).props.onShare as () => void)(); + }); + + // Close the viewer while the share is in flight. + const openViewers = findByType(root, 'ImageViewerModal'); + await act(async () => { + await Promise.resolve(); + (first(openViewers).props.onClose as () => void)(); + }); + + await act(async () => { + shareHolder.reject?.(new Error('boom')); + await Promise.resolve(); + }); + await flushAsync(); + + expect(toastMock.error).toHaveBeenCalledWith('Share failed'); + + await unmount(renderer); + }); + it('renders share failures inline in the Markdown preview instead of toasting', async () => { expoFileSystemMock.fileText.mockResolvedValue('# Hello'); cacheFilePart('part-1', { diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 99e3ca1e32..1d4d058767 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -2,7 +2,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { type FilePart } from '@kilocode/cloud-agent-sdk'; import { Directory, File, Paths } from 'expo-file-system'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Modal, Pressable, ScrollView, View } from 'react-native'; import { toast } from 'sonner-native'; @@ -108,6 +108,17 @@ export function FilePartRenderer({ part }: Readonly) { const [sharing, setSharing] = useState(false); const [shareError, setShareError] = useState(null); + const viewerVisibleRef = useRef(viewerVisible); + const previewRef = useRef(preview); + + useEffect(() => { + viewerVisibleRef.current = viewerVisible; + }, [viewerVisible]); + + useEffect(() => { + previewRef.current = preview; + }, [preview]); + async function handleShare() { if (!url) { return; @@ -124,7 +135,7 @@ export function FilePartRenderer({ part }: Readonly) { } else if (error instanceof ShareRemoteFileError) { message = 'Failed to share file. Please try again.'; } - if (viewerVisible || preview) { + if (viewerVisibleRef.current || previewRef.current) { setShareError(message); } else { toast.error(message); From 6ff52623235505871f23472819a0905b205d0f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 22:54:59 +0200 Subject: [PATCH 09/12] fix(mobile): block send during upload and fix preview error states Disable Send while an image uploads, matching the new-session start gate. Render the image viewer decode fallback in white on the black canvas for WCAG contrast. Move the file preview share error out of the scroll view so it stays visible after scroll. --- .../agents/chat-composer-input-state.test.ts | 31 +++++++++++++++++ .../agents/chat-composer-input-state.ts | 10 ++++-- .../src/components/agents/chat-composer.tsx | 1 + .../file-part-renderer.mounted.test.tsx | 33 +++++++++++++++++++ .../components/agents/file-part-renderer.tsx | 8 ++--- .../image-viewer-modal.mounted.test.tsx | 5 ++- .../src/components/image-viewer-modal.tsx | 4 +-- 7 files changed, 82 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer-input-state.test.ts b/apps/mobile/src/components/agents/chat-composer-input-state.test.ts index 2c4ccfc499..a8a6f1ede0 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-state.test.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-state.test.ts @@ -12,6 +12,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -39,6 +40,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: override.isSending, + isUploading: false, voiceInputActive: false, }); @@ -59,6 +61,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -78,6 +81,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -96,6 +100,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -113,6 +118,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -130,6 +136,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -147,12 +154,31 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); expect(state.canSend).toBe(true); }); + it('blocks send while an upload is in flight, even with text and sendable attachments', () => { + const state = resolveChatComposerControlState({ + attachmentsCount: 1, + sendableAttachmentsCount: 1, + attachmentMax: 5, + disabled: false, + hasText: true, + isFocused: false, + isSending: false, + isUploading: true, + voiceInputActive: false, + }); + + expect(state.canSend).toBe(false); + expect(state.toolbarDisabled).toBe(false); + expect(state.inputEditable).toBe(true); + }); + it('keeps the toolbar visible when focused, has text, has attachments, or voice is active', () => { const base = { attachmentsCount: 0, @@ -162,6 +188,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }; @@ -185,6 +212,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -200,6 +228,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: true, + isUploading: false, voiceInputActive: false, }); @@ -215,6 +244,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: true, }); @@ -232,6 +262,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); diff --git a/apps/mobile/src/components/agents/chat-composer-input-state.ts b/apps/mobile/src/components/agents/chat-composer-input-state.ts index ab16c86e5a..630d3a5f1a 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-state.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-state.ts @@ -7,6 +7,8 @@ type ChatComposerControlInput = { hasText: boolean; isFocused: boolean; isSending: boolean; + /** True while an attachment upload is in flight; blocks send until it settles. */ + isUploading: boolean; voiceInputActive: boolean; }; @@ -46,13 +48,15 @@ export function resolveChatComposerControlState( hasText, isFocused, isSending, + isUploading, voiceInputActive, } = input; // Streaming is intentionally NOT a composer gate. The user must be able to // type and send while the agent runs (plan §3.3): the row component chooses // Stop vs Send based on `isStreaming` + `hasText`. The session manager, the - // parent, and `disabled` already cover every other lock (read-only, missing - // model, blocking interaction, upload-in-progress, interrupt-in-flight). + // parent, and `disabled` cover every other lock (read-only, missing model, + // blocking interaction, interrupt-in-flight); `isUploading` covers the + // upload-in-progress lock. const toolbarDisabled = disabled || isSending; const voiceDisabled = toolbarDisabled; const paperclipDisabled = @@ -60,7 +64,7 @@ export function resolveChatComposerControlState( const inputEditable = !toolbarDisabled && !voiceInputActive; const showToolbar = isFocused || hasText || attachmentsCount > 0 || voiceInputActive; return { - canSend: (hasText || sendableAttachmentsCount > 0) && !disabled && !isSending, + canSend: (hasText || sendableAttachmentsCount > 0) && !disabled && !isSending && !isUploading, inputAccessibilityDisabled: !inputEditable, inputEditable, paperclipDisabled, diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 5f455b2429..e7b6a073fc 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -515,6 +515,7 @@ export function ChatComposer({ hasText, isFocused, isSending, + isUploading: upload.isUploading, voiceInputActive: voiceInput.isActive, }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 1088600fa3..e04b9ebc8e 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -880,6 +880,39 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + it('renders the share error outside the scroll view, directly under the header', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Hello'); + cacheFilePart('part-1', { + url: 'data:text/markdown;base64,QUJD', + mime: 'text/markdown', + filename: 'readme.md', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, 'Preview readme.md'))); + await flushAsync(); + + shareRemoteFileMock.shareLocalFile.mockRejectedValueOnce(new Error('boom')); + shareRemoteFileMock.getShareRemoteFileReason.mockReturnValueOnce(null); + + const headers = findByType(root, 'SheetHeader'); + await act(async () => { + await Promise.resolve(); + (first(headers).props.onShare as () => void)(); + }); + await flushAsync(); + + expect(texts(root)).toContain('Share failed'); + const scrollViews = findByType(root, 'ScrollView'); + expect(scrollViews).toHaveLength(1); + expect(texts(first(scrollViews))).not.toContain('Share failed'); + + await unmount(renderer); + }); + it('renders the retryable share error inline in the image viewer', async () => { cacheFilePart('part-1', { url: 'https://x/a.png', mime: 'image/png', filename: 'shot.png' }); const renderer = await mount( diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 1d4d058767..761f7af9b1 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -459,10 +459,10 @@ function FilePreviewModal({ onShare={onShare} sharing={sharing} /> - - {shareError ? {shareError} : null} - {renderBody()} - + {shareError ? ( + {shareError} + ) : null} + {renderBody()} ); diff --git a/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx b/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx index cb08ce02ac..bce1c3d6af 100644 --- a/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx +++ b/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx @@ -103,11 +103,14 @@ describe('ImageViewerModal mounted', () => { // The zoomable image is replaced by the fallback. expect(findByType(renderer.root, 'Image')).toHaveLength(0); - expect(findByType(renderer.root, 'AlertCircle')).toHaveLength(1); + const alert = findByType(renderer.root, 'AlertCircle'); + expect(alert).toHaveLength(1); + expect(alert[0]?.props.color).toBe('#ffffff'); const unavailable = findByType(renderer.root, 'Text').filter( node => node.props.children === 'Image unavailable' ); expect(unavailable).toHaveLength(1); + expect(unavailable[0]?.props.className).toContain('text-white'); // The Share header pressable stays enabled when onShare exists. const share = findByType(renderer.root, 'Pressable').find( diff --git a/apps/mobile/src/components/image-viewer-modal.tsx b/apps/mobile/src/components/image-viewer-modal.tsx index 8cd7810b13..163b7bfa60 100644 --- a/apps/mobile/src/components/image-viewer-modal.tsx +++ b/apps/mobile/src/components/image-viewer-modal.tsx @@ -170,8 +170,8 @@ export function ImageViewerModal({ ) : null} {uri && imageError ? ( - - Image unavailable + + Image unavailable ) : null} From 5ace8a83b723a88c3df97db6a122a8c893646782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 23:10:34 +0200 Subject: [PATCH 10/12] fix(mobile): announce share failures to screen readers Render both inline share errors through AccessibleStatus so VoiceOver and TalkBack announce the failure, keeping the current visual styling. --- .../file-part-renderer.mounted.test.tsx | 22 +++++++++++++++ .../components/agents/file-part-renderer.tsx | 5 ++-- .../image-viewer-modal.mounted.test.tsx | 27 +++++++++++++++++++ .../src/components/image-viewer-modal.tsx | 8 +++--- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index e04b9ebc8e..c9d7442b89 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ShareRemoteFileError } from '@/lib/share-remote-file'; +import { AccessibleStatus } from '@/components/ui/accessible-status'; import { FilePartRenderer } from './file-part-renderer'; import { __resetFilePartCacheForTests, @@ -104,10 +105,14 @@ vi.mock('@/lib/trpc', () => ({ vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', Modal: 'Modal', + Platform: { OS: 'android' as const }, Pressable: 'Pressable', ScrollView: 'ScrollView', View: 'View', })); +vi.mock('@/lib/a11y/announce', () => ({ + announceForA11y: vi.fn(), +})); vi.mock('@/components/ui/icons', () => ({ AlertCircle: 'AlertCircle', File: 'File' })); vi.mock('@/components/image-viewer-modal', () => ({ ImageViewerModal: 'ImageViewerModal' })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); @@ -215,6 +220,12 @@ function pressableByLabel( ); } +function accessibleStatusNodes( + root: TestRenderer.ReactTestInstance +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => node.type === AccessibleStatus); +} + function texts(root: TestRenderer.ReactTestInstance): string[] { return root .findAll(node => typeof node.type === 'string' && (node.type as string) === 'Text') @@ -877,6 +888,13 @@ describe('FilePartRenderer mounted', () => { expect(texts(root)).toContain('Share failed'); expect(toastMock.error).not.toHaveBeenCalled(); + const statuses = accessibleStatusNodes(root); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.props.message).toBe('Share failed'); + expect(statuses[0]?.props.className).toBe('px-6 pt-2 text-sm'); + const statusText = findByType(first(statuses), 'Text'); + expect(statusText[0]?.props.className).toContain('text-destructive'); + await unmount(renderer); }); @@ -968,6 +986,10 @@ describe('FilePartRenderer mounted', () => { expect(texts(root)).toContain('File sharing is not available on this device.'); expect(toastMock.error).not.toHaveBeenCalled(); + const statuses = accessibleStatusNodes(root); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.props.message).toBe('File sharing is not available on this device.'); + await unmount(renderer); }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 761f7af9b1..d1f029a656 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner-native'; import { ImageViewerModal } from '@/components/image-viewer-modal'; import { SheetHeader } from '@/components/sheet-header'; +import { AccessibleStatus } from '@/components/ui/accessible-status'; import { AlertCircle, File as FileIcon } from '@/components/ui/icons'; import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; @@ -459,9 +460,7 @@ function FilePreviewModal({ onShare={onShare} sharing={sharing} /> - {shareError ? ( - {shareError} - ) : null} + {renderBody()} diff --git a/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx b/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx index bce1c3d6af..3db710bb37 100644 --- a/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx +++ b/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx @@ -4,6 +4,7 @@ import TestRenderer, { act } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { ImageViewerModal } from './image-viewer-modal'; +import { AccessibleStatus } from '@/components/ui/accessible-status'; // A chainable gesture stub: each builder method returns the same object so the // modal's Pinch/Pan/Tap/Race/Simultaneous chains resolve without RNGH. @@ -17,9 +18,13 @@ function makeGesture(): Record { vi.mock('react-native', () => ({ Modal: 'Modal', + Platform: { OS: 'android' as const }, Pressable: 'Pressable', View: 'View', })); +vi.mock('@/lib/a11y/announce', () => ({ + announceForA11y: vi.fn(), +})); vi.mock('@/components/ui/icons', () => ({ Share: 'Share', X: 'X', @@ -124,4 +129,26 @@ describe('ImageViewerModal mounted', () => { renderer.unmount(); }); + + it('renders the share error through AccessibleStatus with white pill text', async () => { + const renderer = await mountViewer({ + shareError: 'Failed to share file. Please try again.', + }); + + const statuses = renderer.root.findAll(node => node.type === AccessibleStatus); + expect(statuses).toHaveLength(1); + const status = statuses[0]; + if (!status) { + throw new Error('AccessibleStatus not found'); + } + expect(status.props.message).toBe('Failed to share file. Please try again.'); + expect(status.props.className).toBe('text-center text-sm text-white dark:text-neutral-900'); + + const text = findByType(status, 'Text'); + expect(text).toHaveLength(1); + expect(text[0]?.props.className).toContain('text-white'); + expect(text[0]?.props.className).not.toContain('text-destructive'); + + renderer.unmount(); + }); }); diff --git a/apps/mobile/src/components/image-viewer-modal.tsx b/apps/mobile/src/components/image-viewer-modal.tsx index 163b7bfa60..c9b2c87c39 100644 --- a/apps/mobile/src/components/image-viewer-modal.tsx +++ b/apps/mobile/src/components/image-viewer-modal.tsx @@ -6,6 +6,7 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-na import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { scheduleOnRN } from 'react-native-worklets'; +import { AccessibleStatus } from '@/components/ui/accessible-status'; import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -182,9 +183,10 @@ export function ImageViewerModal({ style={{ bottom: insets.bottom + 16 }} > - - {shareError} - + ) : null} From cabbb9d50554584e3e6e4e1d3b433f2c8b9385a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 23:18:18 +0200 Subject: [PATCH 11/12] fix(mobile): announce sheet share as busy to screen readers Add accessibilityState busy to the SheetHeader Share pressable so VoiceOver announces a share in progress instead of a dimmed button, matching the image viewer. --- apps/mobile/src/components/sheet-header.mounted.test.tsx | 2 ++ apps/mobile/src/components/sheet-header.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/mobile/src/components/sheet-header.mounted.test.tsx b/apps/mobile/src/components/sheet-header.mounted.test.tsx index 7764d1ad31..d88298f21c 100644 --- a/apps/mobile/src/components/sheet-header.mounted.test.tsx +++ b/apps/mobile/src/components/sheet-header.mounted.test.tsx @@ -56,6 +56,7 @@ describe('SheetHeader share action', () => { expect(shares).toHaveLength(1); expect(shares[0]?.props.accessibilityRole).toBe('button'); expect(shares[0]?.props.disabled).toBe(false); + expect(shares[0]?.props.accessibilityState).toEqual({ disabled: false, busy: false }); renderer.unmount(); }); @@ -71,6 +72,7 @@ describe('SheetHeader share action', () => { const shares = pressablesByLabel(renderer.root, 'Share report.pdf'); expect(shares).toHaveLength(1); expect(shares[0]?.props.disabled).toBe(true); + expect(shares[0]?.props.accessibilityState).toEqual({ disabled: false, busy: true }); renderer.unmount(); }); diff --git a/apps/mobile/src/components/sheet-header.tsx b/apps/mobile/src/components/sheet-header.tsx index 3f879a2cc0..42043cd628 100644 --- a/apps/mobile/src/components/sheet-header.tsx +++ b/apps/mobile/src/components/sheet-header.tsx @@ -42,6 +42,7 @@ export function SheetHeader({ hitSlop={8} accessibilityRole="button" accessibilityLabel={`Share ${title}`} + accessibilityState={{ disabled: false, busy: sharing }} className="absolute left-0 px-2 py-2 active:opacity-70 disabled:opacity-50" > From f6ae62625c934a0bc3199673e4cc4ddf4eef2f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 23:34:53 +0200 Subject: [PATCH 12/12] fix(mobile): keep Send visible during streaming while an upload runs Decouple the Stop-vs-Send decision from the upload state. The row now chooses Stop only when there is no sendable content, so an in-flight upload no longer swaps a streaming session's Send button for Stop. --- .../chat-composer-input-row.mounted.test.tsx | 42 +++++++++++++++++++ .../agents/chat-composer-input-row.tsx | 4 +- .../agents/chat-composer-input-state.test.ts | 9 ++++ .../agents/chat-composer-input-state.ts | 6 ++- .../src/components/agents/chat-composer.tsx | 1 + 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx b/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx index ed5819a2c6..2b52e6a5dd 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx @@ -27,7 +27,10 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ })); type RenderProps = { + canSend?: boolean; + hasSendableContent?: boolean; inputEditable: boolean; + isStreaming?: boolean; }; function makeProps(overrides: Partial = {}) { @@ -35,6 +38,7 @@ function makeProps(overrides: Partial = {}) { attachmentsEnabled: false, canSend: false, disabled: false, + hasSendableContent: false, inputAccessibilityDisabled: false, inputEditable: false, inputRef: { current: null }, @@ -65,6 +69,16 @@ function findTextInput(root: TestRenderer.ReactTestInstance): TestRenderer.React return root.find(node => typeof node.type === 'string' && (node.type as string) === 'TextInput'); } +function findByAccessibilityLabel( + root: TestRenderer.ReactTestInstance, + label: string +): TestRenderer.ReactTestInstance | null { + const matches = root.findAll( + node => typeof node.type === 'string' && node.props.accessibilityLabel === label + ); + return matches[0] ?? null; +} + async function renderRow(props: RenderProps): Promise { const holder: { current?: TestRenderer.ReactTestRenderer } = {}; await act(async () => { @@ -102,4 +116,32 @@ describe('ChatComposerInputRow mounted — iOS writing-tools lock', () => { renderer.unmount(); }); + + it('shows the Send pressable (not Stop) while streaming with content and an in-flight upload', async () => { + const renderer = await renderRow({ + inputEditable: true, + isStreaming: true, + canSend: false, + hasSendableContent: true, + }); + + expect(findByAccessibilityLabel(renderer.root, 'Send message')).not.toBeNull(); + expect(findByAccessibilityLabel(renderer.root, 'Stop generating')).toBeNull(); + + renderer.unmount(); + }); + + it('shows the Stop pressable while streaming with no content', async () => { + const renderer = await renderRow({ + inputEditable: true, + isStreaming: true, + canSend: false, + hasSendableContent: false, + }); + + expect(findByAccessibilityLabel(renderer.root, 'Stop generating')).not.toBeNull(); + expect(findByAccessibilityLabel(renderer.root, 'Send message')).toBeNull(); + + renderer.unmount(); + }); }); diff --git a/apps/mobile/src/components/agents/chat-composer-input-row.tsx b/apps/mobile/src/components/agents/chat-composer-input-row.tsx index ba5c7fa25b..31cd68dbdb 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.tsx @@ -23,6 +23,7 @@ type ChatComposerInputRowProps = { attachmentsEnabled: boolean; canSend: boolean; disabled: boolean; + hasSendableContent: boolean; inputAccessibilityDisabled: boolean; inputEditable: boolean; inputRef: RefObject; @@ -58,6 +59,7 @@ export function ChatComposerInputRow({ attachmentsEnabled, canSend, disabled, + hasSendableContent, inputAccessibilityDisabled, inputEditable, inputRef, @@ -144,7 +146,7 @@ export function ChatComposerInputRow({ ) : null} - {isStreaming && !canSend && !isSending ? ( + {isStreaming && !hasSendableContent && !isSending ? ( { expect(state).toEqual({ canSend: true, + hasSendableContent: true, inputEditable: true, inputAccessibilityDisabled: false, paperclipDisabled: false, @@ -45,6 +46,7 @@ describe('resolveChatComposerControlState', () => { }); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(true); expect(state.toolbarDisabled).toBe(true); expect(state.voiceDisabled).toBe(true); expect(state.inputEditable).toBe(false); @@ -70,6 +72,7 @@ describe('resolveChatComposerControlState', () => { expect(state.toolbarDisabled).toBe(false); expect(state.voiceDisabled).toBe(false); expect(state.canSend).toBe(true); + expect(state.hasSendableContent).toBe(true); }); it('keeps the input editable while streaming with an empty draft (canSend stays false)', () => { @@ -89,6 +92,7 @@ describe('resolveChatComposerControlState', () => { expect(state.inputAccessibilityDisabled).toBe(false); expect(state.toolbarDisabled).toBe(false); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(false); }); it('still blocks send mid-stream when the parent disabled flag is on (e.g. read-only or capability gate)', () => { @@ -105,6 +109,7 @@ describe('resolveChatComposerControlState', () => { }); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(true); expect(state.inputEditable).toBe(false); expect(state.toolbarDisabled).toBe(true); }); @@ -123,6 +128,7 @@ describe('resolveChatComposerControlState', () => { }); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(false); expect(state.toolbarDisabled).toBe(false); expect(state.showToolbar).toBe(true); }); @@ -141,6 +147,7 @@ describe('resolveChatComposerControlState', () => { }); expect(state.canSend).toBe(true); + expect(state.hasSendableContent).toBe(true); expect(state.toolbarDisabled).toBe(false); expect(state.showToolbar).toBe(true); }); @@ -159,6 +166,7 @@ describe('resolveChatComposerControlState', () => { }); expect(state.canSend).toBe(true); + expect(state.hasSendableContent).toBe(true); }); it('blocks send while an upload is in flight, even with text and sendable attachments', () => { @@ -175,6 +183,7 @@ describe('resolveChatComposerControlState', () => { }); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(true); expect(state.toolbarDisabled).toBe(false); expect(state.inputEditable).toBe(true); }); diff --git a/apps/mobile/src/components/agents/chat-composer-input-state.ts b/apps/mobile/src/components/agents/chat-composer-input-state.ts index 630d3a5f1a..4027e01790 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-state.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-state.ts @@ -15,6 +15,8 @@ type ChatComposerControlInput = { type ChatComposerControlState = { /** Backend accepts an empty prompt when at least one attachment is sendable. */ canSend: boolean; + /** True when there is text or a sendable attachment, regardless of upload/send locks. */ + hasSendableContent: boolean; /** Mirrors `editable` on the text input. */ inputEditable: boolean; /** Mirrors `accessibilityState.disabled` on the text input. */ @@ -63,8 +65,10 @@ export function resolveChatComposerControlState( toolbarDisabled || voiceInputActive || attachmentsCount >= attachmentMax; const inputEditable = !toolbarDisabled && !voiceInputActive; const showToolbar = isFocused || hasText || attachmentsCount > 0 || voiceInputActive; + const hasSendableContent = hasText || sendableAttachmentsCount > 0; return { - canSend: (hasText || sendableAttachmentsCount > 0) && !disabled && !isSending && !isUploading, + canSend: hasSendableContent && !disabled && !isSending && !isUploading, + hasSendableContent, inputAccessibilityDisabled: !inputEditable, inputEditable, paperclipDisabled, diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index e7b6a073fc..84d89fe2a2 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -977,6 +977,7 @@ export function ChatComposer({ attachmentsEnabled={attachmentsEnabled} canSend={control.canSend} disabled={disabled} + hasSendableContent={control.hasSendableContent} inputAccessibilityDisabled={control.inputAccessibilityDisabled} inputEditable={control.inputEditable} inputRef={inputRef}