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
3 changes: 3 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ app-example
/ios
/android

# generated by @sentry/react-native/expo prebuild
sentry.options.json

# superpowers brainstorming
.superpowers/

Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ import {
useAgentAttachmentUpload,
} from '@/lib/agent-attachments/use-agent-attachment-upload';
import { describeClassificationFailure } from '@/lib/agent-attachments/validate';
import { useClipboardPaste } from '@/lib/agent-attachments/use-clipboard-paste';
import {
CLIPBOARD_PASTE_EMPTY_MESSAGE,
useClipboardPaste,
} from '@/lib/agent-attachments/use-clipboard-paste';
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
Expand Down Expand Up @@ -422,8 +425,12 @@ export function ChatComposer({
onChangeText: handleChangeText,
});
},
onUnreadable: () => {
toast.error(describeClassificationFailure('unreadable'));
onFailure: reason => {
toast.error(
reason === 'empty'
? CLIPBOARD_PASTE_EMPTY_MESSAGE
: describeClassificationFailure('unreadable')
);
},
});

Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/components/agents/new-session-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ import {
type AgentAttachmentCandidate,
} from '@/lib/agent-attachments/use-agent-attachment-upload';
import { describeClassificationFailure } from '@/lib/agent-attachments/validate';
import { useClipboardPaste } from '@/lib/agent-attachments/use-clipboard-paste';
import {
CLIPBOARD_PASTE_EMPTY_MESSAGE,
useClipboardPaste,
} from '@/lib/agent-attachments/use-clipboard-paste';

const PROMPT_INPUT_DEFAULT_LINES = 3;
const PROMPT_INPUT_MAX_LINES = 6;
Expand Down Expand Up @@ -204,8 +207,12 @@ export function NewSessionPrompt({
onChangeText: handlePromptChange,
});
},
onUnreadable: () => {
toast.error(describeClassificationFailure('unreadable'));
onFailure: reason => {
toast.error(
reason === 'empty'
? CLIPBOARD_PASTE_EMPTY_MESSAGE
: describeClassificationFailure('unreadable')
);
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { toast } from 'sonner-native';

import { useClipboardPaste } from '@/lib/agent-attachments/use-clipboard-paste';
import {
CLIPBOARD_PASTE_EMPTY_MESSAGE,
useClipboardPaste,
} from '@/lib/agent-attachments/use-clipboard-paste';
import { buildAttachmentUnreadableToast } from './message-attachment-state';
import { type ComposerAttachmentQueue } from './message-input-types';

Expand All @@ -22,8 +25,12 @@ export function useMessageInputClipboardImageHint({
addFile: async file => {
await attachmentQueue?.addClipboardImage(file);
},
onUnreadable: () => {
toast.error(buildAttachmentUnreadableToast('the pasted image'));
onFailure: reason => {
toast.error(
reason === 'empty'
? CLIPBOARD_PASTE_EMPTY_MESSAGE
: buildAttachmentUnreadableToast('the pasted image')
);
},
});
}
15 changes: 15 additions & 0 deletions apps/mobile/src/lib/agent-attachments/clipboard-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
hasClipboardImage,
hasClipboardUrl,
parseClipboardImageData,
readClipboardImageFile,
} from './clipboard-image';
Expand Down Expand Up @@ -50,11 +51,13 @@ vi.mock('expo-file-system', () => ({

const clipboardMock = vi.hoisted(() => ({
hasImageAsync: vi.fn(),
hasUrlAsync: vi.fn(),
getImageAsync: vi.fn(),
}));

vi.mock('expo-clipboard', () => ({
hasImageAsync: clipboardMock.hasImageAsync,
hasUrlAsync: clipboardMock.hasUrlAsync,
getImageAsync: clipboardMock.getImageAsync,
}));

Expand Down Expand Up @@ -119,6 +122,18 @@ describe('hasClipboardImage', () => {
});
});

describe('hasClipboardUrl', () => {
it('returns true when hasUrlAsync resolves true', async () => {
clipboardMock.hasUrlAsync.mockResolvedValue(true);
await expect(hasClipboardUrl()).resolves.toBe(true);
});

it('returns false when hasUrlAsync rejects', async () => {
clipboardMock.hasUrlAsync.mockRejectedValue(new Error('denied'));
await expect(hasClipboardUrl()).resolves.toBe(false);
});
});

describe('readClipboardImageFile', () => {
it('writes a PNG file with the correct name and encoding', async () => {
clipboardMock.getImageAsync.mockResolvedValue({
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/lib/agent-attachments/clipboard-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export async function hasClipboardImage(): Promise<boolean> {
}
}

/**
* Check whether the clipboard holds a URL.
* Uses `hasUrlAsync`, which is iOS/macOS only and raises no iOS paste
* prompt. Returns `false` on any error (including the Android
* `UnavailabilityError`).
*/
export async function hasClipboardUrl(): Promise<boolean> {
try {
return await Clipboard.hasUrlAsync();
} catch {
return false;
}
}

/**
* Read the clipboard text. Returns `''` when the clipboard holds no text,
* the read was denied, or the read failed.
Expand Down
120 changes: 96 additions & 24 deletions apps/mobile/src/lib/agent-attachments/use-clipboard-paste.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* eslint-disable max-lines -- the paste hook classifies empty vs unreadable across image, text, and URL states; one cohesive suite pins each reason. */
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useClipboardPaste } from './use-clipboard-paste';

// ── Hoisted mocks ─────────────────────────────────────────────────────────

const hasClipboardImageMock = vi.hoisted(() => vi.fn<() => Promise<boolean>>());
const hasClipboardUrlMock = vi.hoisted(() => vi.fn<() => Promise<boolean>>());
const readClipboardImageFileMock = vi.hoisted(() =>
vi.fn<() => Promise<{ uri: string; name: string; mimeType: string } | null>>()
);
Expand All @@ -13,6 +15,7 @@ const setHasImageMock = vi.hoisted(() => vi.fn<(value: boolean) => void>());

vi.mock('./clipboard-image', () => ({
hasClipboardImage: hasClipboardImageMock,
hasClipboardUrl: hasClipboardUrlMock,
readClipboardImageFile: readClipboardImageFileMock,
readClipboardText: readClipboardTextMock,
}));
Expand Down Expand Up @@ -42,13 +45,13 @@ function makeOptions(overrides?: {
enabled?: boolean;
addFile?: () => Promise<void>;
addText?: (text: string) => void;
onUnreadable?: () => void;
onFailure?: (reason: 'empty' | 'unreadable') => void;
}) {
return {
enabled: overrides?.enabled ?? true,
addFile: overrides?.addFile ?? vi.fn().mockResolvedValue(undefined),
addText: overrides?.addText,
onUnreadable: overrides?.onUnreadable ?? vi.fn<() => void>(),
onFailure: overrides?.onFailure ?? vi.fn<(reason: 'empty' | 'unreadable') => void>(),
};
}

Expand All @@ -57,6 +60,17 @@ async function flushMicrotasks() {
await Promise.resolve();
}

/** Flush microtasks until `fn` has been called, up to `max` flushes. */
async function flushUntilCalled(fn: { mock: { calls: unknown[] } }, max = 8) {
for (let i = 0; i < max; i += 1) {
if (fn.mock.calls.length > 0) {
return;
}
// eslint-disable-next-line no-await-in-loop -- flush one microtask at a time until the mock is called.
await Promise.resolve();
}
}

/** Extract the last boolean argument passed to setHasImage. */
function lastSetHasImageArg(): boolean | undefined {
const calls = setHasImageMock.mock.calls;
Expand Down Expand Up @@ -180,8 +194,8 @@ describe('useClipboardPaste', () => {
hasClipboardImageMock.mockResolvedValue(true);
readClipboardImageFileMock.mockResolvedValue(null);

const onUnreadable = vi.fn<() => void>();
const hook = useClipboardPaste(makeOptions({ onUnreadable }));
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ onFailure }));

// Refresh shows the image.
hook.refresh();
Expand All @@ -190,9 +204,10 @@ describe('useClipboardPaste', () => {

// Paste fails — readClipboardImageFile returns null.
hook.paste();
await flushMicrotasks();
await flushUntilCalled(onFailure);

expect(onUnreadable).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledWith('unreadable');

// Subsequent refresh can still show the image (not consumed).
hook.refresh();
Expand All @@ -208,18 +223,20 @@ describe('useClipboardPaste', () => {
readClipboardTextMock.mockResolvedValue('https://example.com/spec');

const addText = vi.fn<(text: string) => void>();
const onUnreadable = vi.fn<() => void>();
const hook = useClipboardPaste(makeOptions({ addText, onUnreadable }));
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ addText, onFailure }));

hook.paste();
await flushMicrotasks();
await flushMicrotasks();

expect(addText).toHaveBeenCalledWith('https://example.com/spec');
expect(onUnreadable).not.toHaveBeenCalled();
expect(onFailure).not.toHaveBeenCalled();
// The image read raises the iOS 16 paste prompt; a text clipboard must
// never reach it.
expect(readClipboardImageFileMock).not.toHaveBeenCalled();
// Success returns before the empty probes run.
expect(hasClipboardUrlMock).not.toHaveBeenCalled();
});

it('pastes the text when the clipboard holds an image it cannot read', async () => {
Expand All @@ -228,8 +245,8 @@ describe('useClipboardPaste', () => {
readClipboardTextMock.mockResolvedValue('fallback text');

const addText = vi.fn<(text: string) => void>();
const onUnreadable = vi.fn<() => void>();
const hook = useClipboardPaste(makeOptions({ addText, onUnreadable }));
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ addText, onFailure }));

hook.paste();
await flushMicrotasks();
Expand All @@ -238,41 +255,96 @@ describe('useClipboardPaste', () => {

expect(readClipboardImageFileMock).toHaveBeenCalledOnce();
expect(addText).toHaveBeenCalledWith('fallback text');
expect(onUnreadable).not.toHaveBeenCalled();
expect(onFailure).not.toHaveBeenCalled();
});

it('toasts unreadable when neither an image nor text is on the clipboard', async () => {
it('toasts empty when neither an image nor text is on the clipboard', async () => {
hasClipboardImageMock.mockResolvedValue(false);
hasClipboardUrlMock.mockResolvedValue(false);
readClipboardImageFileMock.mockResolvedValue(null);
readClipboardTextMock.mockResolvedValue('');

const addText = vi.fn<(text: string) => void>();
const onUnreadable = vi.fn<() => void>();
const hook = useClipboardPaste(makeOptions({ addText, onUnreadable }));
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ addText, onFailure }));

hook.paste();
await flushMicrotasks();
await flushMicrotasks();
await flushUntilCalled(onFailure);

expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledWith('empty');
expect(addText).not.toHaveBeenCalled();
expect(onUnreadable).toHaveBeenCalledOnce();
expect(readClipboardImageFileMock).not.toHaveBeenCalled();
expect(readClipboardTextMock).toHaveBeenCalledOnce();
});

it('keeps the image-only behavior when the caller omits addText', async () => {
hasClipboardImageMock.mockResolvedValue(true);
readClipboardImageFileMock.mockResolvedValue(null);
readClipboardTextMock.mockResolvedValue('some text');

const onUnreadable = vi.fn<() => void>();
const hook = useClipboardPaste(makeOptions({ onUnreadable }));
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ onFailure }));

hook.paste();
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
await flushUntilCalled(onFailure);

expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledWith('unreadable');
expect(readClipboardTextMock).not.toHaveBeenCalled();
expect(onUnreadable).toHaveBeenCalledOnce();
expect(hasClipboardUrlMock).not.toHaveBeenCalled();
});

it('toasts empty when an image-only caller finds no image', async () => {
hasClipboardImageMock.mockResolvedValue(false);
readClipboardImageFileMock.mockResolvedValue(null);

const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ onFailure }));

hook.paste();
await flushUntilCalled(onFailure);

expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledWith('empty');
expect(readClipboardImageFileMock).not.toHaveBeenCalled();
expect(readClipboardTextMock).not.toHaveBeenCalled();
});

it('toasts unreadable when text read returns empty but a URL is present', async () => {
hasClipboardImageMock.mockResolvedValue(false);
hasClipboardUrlMock.mockResolvedValue(true);
readClipboardImageFileMock.mockResolvedValue(null);
readClipboardTextMock.mockResolvedValue('');

const addText = vi.fn<(text: string) => void>();
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ addText, onFailure }));

hook.paste();
await flushUntilCalled(onFailure);

expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledWith('unreadable');
expect(addText).not.toHaveBeenCalled();
});

it('toasts unreadable when an image is present but unreadable and no text is present', async () => {
hasClipboardImageMock.mockResolvedValue(true);
readClipboardImageFileMock.mockResolvedValue(null);
readClipboardTextMock.mockResolvedValue('');

const addText = vi.fn<(text: string) => void>();
const onFailure = vi.fn<(reason: 'empty' | 'unreadable') => void>();
const hook = useClipboardPaste(makeOptions({ addText, onFailure }));

hook.paste();
await flushUntilCalled(onFailure);

expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledWith('unreadable');
// The stored hasImage result is enough; no URL probe runs.
expect(hasClipboardUrlMock).not.toHaveBeenCalled();
});

// ── Non-retryable unhappy: addFile rejection still consumes ─────────────
Expand Down
Loading