diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index ce34d3682c..04d7fde62c 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -42,6 +42,9 @@ app-example /ios /android +# generated by @sentry/react-native/expo prebuild +sentry.options.json + # superpowers brainstorming .superpowers/ diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 7db630de06..1cd0a113e1 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -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'; @@ -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') + ); }, }); diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx index 03e0526824..ca12a2f960 100644 --- a/apps/mobile/src/components/agents/new-session-prompt.tsx +++ b/apps/mobile/src/components/agents/new-session-prompt.tsx @@ -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; @@ -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') + ); }, }); diff --git a/apps/mobile/src/components/kilo-chat/use-message-input-clipboard-image-hint.ts b/apps/mobile/src/components/kilo-chat/use-message-input-clipboard-image-hint.ts index 89979f79a0..efbcc864c0 100644 --- a/apps/mobile/src/components/kilo-chat/use-message-input-clipboard-image-hint.ts +++ b/apps/mobile/src/components/kilo-chat/use-message-input-clipboard-image-hint.ts @@ -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'; @@ -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') + ); }, }); } diff --git a/apps/mobile/src/lib/agent-attachments/clipboard-image.test.ts b/apps/mobile/src/lib/agent-attachments/clipboard-image.test.ts index 9742c7acec..ebd1a12ed9 100644 --- a/apps/mobile/src/lib/agent-attachments/clipboard-image.test.ts +++ b/apps/mobile/src/lib/agent-attachments/clipboard-image.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { hasClipboardImage, + hasClipboardUrl, parseClipboardImageData, readClipboardImageFile, } from './clipboard-image'; @@ -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, })); @@ -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({ diff --git a/apps/mobile/src/lib/agent-attachments/clipboard-image.ts b/apps/mobile/src/lib/agent-attachments/clipboard-image.ts index 024cb6c91a..ab74c08bbc 100644 --- a/apps/mobile/src/lib/agent-attachments/clipboard-image.ts +++ b/apps/mobile/src/lib/agent-attachments/clipboard-image.ts @@ -61,6 +61,20 @@ export async function hasClipboardImage(): Promise { } } +/** + * 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 { + 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. diff --git a/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.test.ts b/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.test.ts index 863250c72a..9a45f38f7e 100644 --- a/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.test.ts +++ b/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.test.ts @@ -1,3 +1,4 @@ +/* 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'; @@ -5,6 +6,7 @@ import { useClipboardPaste } from './use-clipboard-paste'; // ── Hoisted mocks ───────────────────────────────────────────────────────── const hasClipboardImageMock = vi.hoisted(() => vi.fn<() => Promise>()); +const hasClipboardUrlMock = vi.hoisted(() => vi.fn<() => Promise>()); const readClipboardImageFileMock = vi.hoisted(() => vi.fn<() => Promise<{ uri: string; name: string; mimeType: string } | null>>() ); @@ -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, })); @@ -42,13 +45,13 @@ function makeOptions(overrides?: { enabled?: boolean; addFile?: () => Promise; 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>(), }; } @@ -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; @@ -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(); @@ -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(); @@ -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 () => { @@ -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(); @@ -238,24 +255,27 @@ 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 () => { @@ -263,16 +283,68 @@ describe('useClipboardPaste', () => { 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 ───────────── diff --git a/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.ts b/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.ts index 9fda759003..23256d86ee 100644 --- a/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.ts +++ b/apps/mobile/src/lib/agent-attachments/use-clipboard-paste.ts @@ -4,10 +4,13 @@ import { AppState, type AppStateStatus } from 'react-native'; import { type ClipboardImageFile, hasClipboardImage, + hasClipboardUrl, readClipboardImageFile, readClipboardText, } from './clipboard-image'; +export const CLIPBOARD_PASTE_EMPTY_MESSAGE = 'Nothing to paste'; + type UseClipboardPasteOptions = { /** Gates `visible` only — `paste` always works. Defaults to true. Pass each * composer's existing "can add an attachment" expression when the caller @@ -21,10 +24,13 @@ type UseClipboardPasteOptions = { * paste with text on the clipboard, and an unreadable-image toast would be * wrong there. Omit it to keep the image-only behavior. */ addText?: (text: string) => void; - /** Called when neither an image nor text could be read (empty, denied, or - * unsupported type). The caller supplies its own unreadable-toast copy to - * match that composer's existing pick-path message. */ - onUnreadable: () => void; + /** Called when neither an image nor text could be used. `'empty'` means no + * image, no readable text, and no URL on the clipboard. `'unreadable'` means + * an image or URL was present but the read failed or was denied. A denied + * text read is indistinguishable from empty and reports 'empty'. The caller + * supplies its own toast copy to match that composer's existing pick-path + * message. */ + onFailure: (reason: 'empty' | 'unreadable') => void; }; type UseClipboardPasteReturn = { @@ -63,11 +69,11 @@ export function useClipboardPaste(options: UseClipboardPasteOptions): UseClipboa // effect re-subscribes when a parent re-renders. const addFileRef = useRef(options.addFile); const addTextRef = useRef(options.addText); - const onUnreadableRef = useRef(options.onUnreadable); + const onFailureRef = useRef(options.onFailure); useEffect(() => { addFileRef.current = options.addFile; addTextRef.current = options.addText; - onUnreadableRef.current = options.onUnreadable; + onFailureRef.current = options.onFailure; }); const inFlightRef = useRef(false); @@ -134,7 +140,8 @@ export function useClipboardPaste(options: UseClipboardPasteOptions): UseClipboa // inspects only the content type, so a text clipboard reaches the text // path without the image read that would raise a second iOS 16 paste // prompt for content that is not there. - const file = (await hasClipboardImage()) ? await readClipboardImageFile() : null; + const clipboardHasImage = await hasClipboardImage(); + const file = clipboardHasImage ? await readClipboardImageFile() : null; if (!file) { // No readable image. A caller with an always-present paste control // accepts text, so a text clipboard pastes instead of toasting. @@ -144,12 +151,25 @@ export function useClipboardPaste(options: UseClipboardPasteOptions): UseClipboa // image must stay retryable: the user can grant the permission or // copy the image again. const addText = addTextRef.current; - const text = addText ? await readClipboardText() : ''; - if (addText && text !== '') { - addText(text); + if (addText) { + const text = await readClipboardText(); + if (text !== '') { + addText(text); + return; + } + } + if (clipboardHasImage) { + onFailureRef.current('unreadable'); return; } - onUnreadableRef.current(); + if (addText) { + const present = await hasClipboardUrl(); + if (present) { + onFailureRef.current('unreadable'); + return; + } + } + onFailureRef.current('empty'); return; } // The clipboard image was read: mark it consumed so a later