Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({
}));

type RenderProps = {
canSend?: boolean;
hasSendableContent?: boolean;
inputEditable: boolean;
isStreaming?: boolean;
};

function makeProps(overrides: Partial<RenderProps> = {}) {
return {
attachmentsEnabled: false,
canSend: false,
disabled: false,
hasSendableContent: false,
inputAccessibilityDisabled: false,
inputEditable: false,
inputRef: { current: null },
Expand Down Expand Up @@ -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<TestRenderer.ReactTestRenderer> {
const holder: { current?: TestRenderer.ReactTestRenderer } = {};
await act(async () => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type ChatComposerInputRowProps = {
attachmentsEnabled: boolean;
canSend: boolean;
disabled: boolean;
hasSendableContent: boolean;
inputAccessibilityDisabled: boolean;
inputEditable: boolean;
inputRef: RefObject<TextInput | null>;
Expand Down Expand Up @@ -58,6 +59,7 @@ export function ChatComposerInputRow({
attachmentsEnabled,
canSend,
disabled,
hasSendableContent,
inputAccessibilityDisabled,
inputEditable,
inputRef,
Expand Down Expand Up @@ -144,7 +146,7 @@ export function ChatComposerInputRow({
</View>
) : null}

{isStreaming && !canSend && !isSending ? (
{isStreaming && !hasSendableContent && !isSending ? (
<Pressable
onPress={onStop}
disabled={disabled}
Expand Down
Loading