Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/app/(app)/agent-chat/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ function NewSessionScreenBody() {
? remoteSpawn.isSpawningRemote ||
isSubmitting ||
attachments.hasFailedAttachments ||
attachments.isUploading ||
modelView.isSelectionUnavailable ||
instanceCatalog.isLoading
: resolveNewSessionStartDisabled({
Expand Down
50 changes: 49 additions & 1 deletion apps/mobile/src/components/agents/attachment-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 & {
Expand Down
31 changes: 24 additions & 7 deletions apps/mobile/src/components/agents/attachment-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.<ext>` 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) ??
Comment thread
iscekic marked this conversation as resolved.
'png';
return {
name,
name: `image.${extension}`,
uri: asset.uri,
mimeType: asset.mimeType ?? undefined,
size: asset.fileSize ?? undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
31 changes: 22 additions & 9 deletions apps/mobile/src/components/agents/attachment-preview-strip.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -131,6 +133,25 @@ function AttachmentChip({
const accessibilityState =
isUploading && attachment.progress === null ? { busy: true } : undefined;

const imageThumbnail = imageFailed ? (
<View className="h-full w-full items-center justify-center">
<AlertCircle size={20} color={colors.mutedForeground} />
</View>
) : (
<Image
source={{ uri: attachment.localUri }}
className="h-full w-full"
contentFit="cover"
transition={0}
allowDownscaling
recyclingKey={attachment.id}
cachePolicy="memory"
onError={() => {
setImageFailed(true);
}}
/>
);

const bodyContent = (
// Visual descendants are excluded from the accessibility tree so
// the body stays the single announced element: the nested Texts,
Expand All @@ -142,15 +163,7 @@ function AttachmentChip({
importantForAccessibility="no-hide-descendants"
>
{isImage ? (
<Image
source={{ uri: attachment.localUri }}
className="h-full w-full"
contentFit="cover"
transition={0}
allowDownscaling
recyclingKey={attachment.id}
cachePolicy="memory"
/>
imageThumbnail
) : (
<View
className={cn(
Expand Down
Loading