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/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/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 ) : ( ({ })); 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 ? ( { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); expect(state).toEqual({ canSend: true, + hasSendableContent: true, inputEditable: true, inputAccessibilityDisabled: false, paperclipDisabled: false, @@ -39,10 +41,12 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: override.isSending, + isUploading: false, voiceInputActive: false, }); 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); @@ -59,6 +63,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -67,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)', () => { @@ -78,6 +84,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -85,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)', () => { @@ -96,10 +104,12 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(true); expect(state.inputEditable).toBe(false); expect(state.toolbarDisabled).toBe(true); }); @@ -113,10 +123,12 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); expect(state.canSend).toBe(false); + expect(state.hasSendableContent).toBe(false); expect(state.toolbarDisabled).toBe(false); expect(state.showToolbar).toBe(true); }); @@ -130,10 +142,12 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); expect(state.canSend).toBe(true); + expect(state.hasSendableContent).toBe(true); expect(state.toolbarDisabled).toBe(false); expect(state.showToolbar).toBe(true); }); @@ -147,10 +161,31 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); 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', () => { + 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.hasSendableContent).toBe(true); + 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', () => { @@ -162,6 +197,7 @@ describe('resolveChatComposerControlState', () => { hasText: false, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }; @@ -185,6 +221,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: false, }); @@ -200,6 +237,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: true, + isUploading: false, voiceInputActive: false, }); @@ -215,6 +253,7 @@ describe('resolveChatComposerControlState', () => { hasText: true, isFocused: false, isSending: false, + isUploading: false, voiceInputActive: true, }); @@ -232,6 +271,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..4027e01790 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-state.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-state.ts @@ -7,12 +7,16 @@ 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; }; 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. */ @@ -46,21 +50,25 @@ 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 = 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, + 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 5f455b2429..84d89fe2a2 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, }); @@ -976,6 +977,7 @@ export function ChatComposer({ attachmentsEnabled={attachmentsEnabled} canSend={control.canSend} disabled={disabled} + hasSendableContent={control.hasSendableContent} inputAccessibilityDisabled={control.inputAccessibilityDisabled} inputEditable={control.inputEditable} inputRef={inputRef} 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 c2abc947ad..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 @@ -5,6 +5,9 @@ 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 { AccessibleStatus } from '@/components/ui/accessible-status'; import { FilePartRenderer } from './file-part-renderer'; import { __resetFilePartCacheForTests, @@ -102,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' })); @@ -213,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') @@ -543,6 +556,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', { @@ -617,6 +666,333 @@ 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('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', { + 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(); + + 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); + }); + + 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( + 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('download-failed') + ); + + 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(); + + 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); + }); + it('presigns a markdown attachment and previews its text', async () => { expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); const uuid = '11111111-1111-4111-8111-111111111111'; diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index f21a16e1cc..d1f029a656 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -2,12 +2,13 @@ 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'; 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'; @@ -106,32 +107,69 @@ 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); + + const viewerVisibleRef = useRef(viewerVisible); + const previewRef = useRef(preview); + + useEffect(() => { + viewerVisibleRef.current = viewerVisible; + }, [viewerVisible]); + + useEffect(() => { + previewRef.current = preview; + }, [preview]); 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 (viewerVisibleRef.current || previewRef.current) { + 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) { if (kind === 'markdown') { - setPreview('markdown'); + openPreview('markdown'); return; } showActionSheetWithOptions( @@ -144,7 +182,7 @@ export function FilePartRenderer({ part }: Readonly) { return; } if (index === 0) { - setPreview('text'); + openPreview('text'); } else if (index === 1) { void handleShare(); } @@ -156,7 +194,7 @@ export function FilePartRenderer({ part }: Readonly) { // A markdown chip tapped while the presign is in flight opens the // modal as soon as the URL lands; the modal render is gated on `url`. if (kind === 'markdown') { - setPreview('markdown'); + openPreview('markdown'); } return; } @@ -173,7 +211,7 @@ export function FilePartRenderer({ part }: Readonly) { useEffect(() => { if (preview !== null && resolved.status === 'error') { toast.error('Could not load this file. Try again.'); - setPreview(null); + closePreview(); } }, [preview, resolved.status]); @@ -206,9 +244,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)} @@ -233,9 +269,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} /> )} @@ -307,9 +346,12 @@ export function FilePartRenderer({ part }: Readonly) { } : undefined } - onClose={() => { - setPreview(null); + onShare={() => { + void handleShare(); }} + sharing={sharing} + shareError={shareError} + onClose={closePreview} /> ) : null} @@ -322,9 +364,21 @@ type FilePreviewModalProps = { part: FilePart; onRetry?: () => Promise; onClose: () => void; + onShare?: () => void; + sharing?: boolean; + shareError?: string | null; }; -function FilePreviewModal({ mode, url, part, onRetry, onClose }: Readonly) { +function FilePreviewModal({ + mode, + url, + part, + onRetry, + onClose, + onShare, + sharing = false, + shareError = null, +}: Readonly) { const { id, mime, filename } = part; const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [text, setText] = useState(''); @@ -399,7 +453,14 @@ function FilePreviewModal({ mode, url, part, onRetry, onClose }: Readonly - + + {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 new file mode 100644 index 0000000000..3db710bb37 --- /dev/null +++ b/apps/mobile/src/components/image-viewer-modal.mounted.test.tsx @@ -0,0 +1,154 @@ +/* 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 animated-splash-overlay.mounted.test.tsx) */ +import { type ComponentProps, createElement } from 'react'; +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. +function makeGesture(): Record { + const gesture: Record = {}; + gesture.onUpdate = () => gesture; + gesture.onEnd = () => gesture; + gesture.numberOfTaps = () => gesture; + return gesture; +} + +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', + 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); + 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( + 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(); + }); + + 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 5d459861c7..c9b2c87c39 100644 --- a/apps/mobile/src/components/image-viewer-modal.tsx +++ b/apps/mobile/src/components/image-viewer-modal.tsx @@ -1,11 +1,12 @@ -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'; 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'; @@ -35,6 +36,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 +66,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 +155,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 ? ( @@ -162,9 +183,10 @@ export function ImageViewerModal({ style={{ bottom: insets.bottom + 16 }} > - - {shareError} - + ) : null} 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..d88298f21c --- /dev/null +++ b/apps/mobile/src/components/sheet-header.mounted.test.tsx @@ -0,0 +1,91 @@ +/* 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); + expect(shares[0]?.props.accessibilityState).toEqual({ disabled: false, busy: 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); + expect(shares[0]?.props.accessibilityState).toEqual({ disabled: false, busy: 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..42043cd628 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,19 @@ export function SheetHeader({ {title} + {onShare !== undefined ? ( + + + + ) : null} {onCancel ? ( { 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/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..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,11 +384,18 @@ export function useAgentAttachmentUpload( return; } commitAttachments(current => [...current, ...additions]); + // 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); + } } }, - [attachments.length, commitAttachments] + [attachments.length, commitAttachments, startUpload] ); const removeAttachment = useCallback( 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.