From ab2d5aea32df9dd8f8815471277a4f91ecad7b1c Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:14:03 -0700 Subject: [PATCH] feat(web): attach images with a file picker, not just paste and drop The composer accepts images by paste and by drag-and-drop, which between them cover a desktop and nothing else: a phone has no file drag source, and mobile browsers do not reliably put a photo on the DOM clipboard, so there is no way to attach an image there at all. Add an attach control backed by a hidden file input (accept="image/*", multiple), which is what reaches the system photo picker. The input is visually hidden rather than display:none, since only the former is reliably clickable from script across engines, and it is cleared after each selection so the same photo can be picked again after removing it. Also reject image types the agent APIs do not accept (anything outside JPEG, PNG, GIF and WebP) with a message in the composer, rather than attaching a file whose send then fails: a photo picked out of a file manager can still be HEIC even where the camera path transcodes to JPEG. The control is an upright paperclip drawn as an inline svg in the same idiom as the sidebar's icons: it takes its colour from the theme, renders identically everywhere, and its height comes from the same custom property that now sizes the send button, so the two match exactly by construction. --- web/src/components/InputBox.attach.test.tsx | 128 ++++++++++++++++++++ web/src/components/InputBox.tsx | 120 ++++++++++++++++++ web/src/styles.css | 53 ++++++++ 3 files changed, 301 insertions(+) create mode 100644 web/src/components/InputBox.attach.test.tsx diff --git a/web/src/components/InputBox.attach.test.tsx b/web/src/components/InputBox.attach.test.tsx new file mode 100644 index 00000000..95316515 --- /dev/null +++ b/web/src/components/InputBox.attach.test.tsx @@ -0,0 +1,128 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "pretendToBeVisual": true } + * + * The composer's attach button. Paste and drag-and-drop were the only ways to + * attach an image, which left phones with no way at all: iOS has no file drag + * source, and gecko-for-iOS does not put pasted photos on the DOM clipboard. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { InputBox, drafts } from "./InputBox"; + +vi.hoisted(() => { + vi.stubGlobal("CSS", { supports: () => false }); +}); + +const SESSION = "attach-session"; + +/** FileReader.readAsDataURL resolves on a task, so let it land. */ +const flushReader = () => new Promise((resolve) => setTimeout(resolve, 20)); + +async function mountComposer(container: HTMLElement) { + await act(() => { + render( + {}} + onInterrupt={() => {}} + isProcessing={false} + stdinClosed={false} + disabled={false} + sessionId={SESSION} + />, + container, + ); + }); +} + +/** A real File whose bytes decode as the given data URL payload. */ +function imageFile(name: string, type: string) { + return new File([new Uint8Array([1, 2, 3, 4])], name, { type }); +} + +/** Drive the hidden input the way a picker selection does. */ +async function selectFiles(container: HTMLElement, files: File[]) { + const input = container.querySelector("input.input-file"); + expect(input).not.toBeNull(); + // a FileList stand-in: indexed access, length, item(), and iteration + const list: Record = { + item: (index: number) => files[index] ?? null, + [Symbol.iterator]: files[Symbol.iterator].bind(files), + }; + files.forEach((file, index) => { + list[index] = file; + }); + list.length = files.length; + Object.defineProperty(input!, "files", { configurable: true, value: list }); + await act(() => { + input!.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("composer image attachment", () => { + let container: HTMLElement; + + beforeEach(() => { + drafts.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + it("offers an attach control that reaches the system picker", async () => { + await mountComposer(container); + + const button = container.querySelector(".btn-attach"); + const input = container.querySelector("input.input-file"); + expect(button).not.toBeNull(); + expect(input).not.toBeNull(); + // accept drives which picker iOS opens; multiple lets a batch through + expect(input!.getAttribute("accept")).toBe("image/*"); + expect(input!.hasAttribute("multiple")).toBe(true); + + let clicked = false; + input!.click = () => { + clicked = true; + }; + await act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(clicked).toBe(true); + }); + + it("attaches picked images as previews", async () => { + await mountComposer(container); + await selectFiles(container, [ + imageFile("one.jpg", "image/jpeg"), + imageFile("two.png", "image/png"), + ]); + await act(async () => { + await flushReader(); + }); + + expect(container.querySelectorAll(".image-preview img").length).toBe(2); + }); + + it("refuses image types the agent APIs reject, and says so", async () => { + await mountComposer(container); + await selectFiles(container, [imageFile("photo.heic", "image/heic")]); + await act(async () => { + await flushReader(); + }); + + expect(container.querySelectorAll(".image-preview img").length).toBe(0); + const error = container.querySelector(".attach-error"); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("image/heic"); + }); + + it("clears the input so the same photo can be picked twice", async () => { + await mountComposer(container); + const input = container.querySelector("input.input-file"); + await selectFiles(container, [imageFile("one.jpg", "image/jpeg")]); + await act(async () => { + await flushReader(); + }); + expect(input!.value).toBe(""); + }); +}); diff --git a/web/src/components/InputBox.tsx b/web/src/components/InputBox.tsx index 636f49e9..b722ba75 100644 --- a/web/src/components/InputBox.tsx +++ b/web/src/components/InputBox.tsx @@ -132,6 +132,16 @@ function publishImages(entry: ControlledImageEntry, images: ImageAttachment[]) { } } +// what the agent APIs actually accept; an iPhone photo arrives as JPEG because +// the picker transcodes it, but a file picked out of Files can still be HEIC, +// and attaching one silently produces a send the API rejects +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + function useImageAttachments( enabled = true, resetToken?: number, @@ -151,6 +161,7 @@ function useImageAttachments( () => imageEntry?.generation ?? 0, ); const [isDragging, setIsDragging] = useState(false); + const [attachError, setAttachError] = useState(null); const enabledRef = useRef(enabled); const resetTokenRef = useRef(resetToken); const generationRef = useRef(imageEntry?.generation ?? 0); @@ -217,6 +228,13 @@ function useImageAttachments( const processFile = (file: File) => { if (!enabledRef.current || !file.type.startsWith("image/")) return; + if (!SUPPORTED_IMAGE_TYPES.has(file.type)) { + setAttachError( + `${file.type} images can't be attached; JPEG, PNG, GIF and WebP work`, + ); + return; + } + setAttachError(null); const generation = generationRef.current; const entry = imageEntry; const reader = new FileReader(); @@ -245,6 +263,11 @@ function useImageAttachments( reader.readAsDataURL(file); }; + const addFiles = (files: FileList | File[] | null) => { + if (!files) return; + for (const file of Array.from(files)) processFile(file); + }; + const onPaste = (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) return; @@ -297,6 +320,11 @@ function useImageAttachments( images: enabled && imagesGeneration === generationRef.current ? images : [], setImages, isDragging: enabled && isDragging, + attachError, + dismissAttachError: () => { + setAttachError(null); + }, + addFiles, onPaste, onDragOver, onDragLeave, @@ -304,6 +332,84 @@ function useImageAttachments( }; } +/** Attach button plus the hidden input it drives. + * + * Paste and drag-and-drop were the only ways to attach an image, and a phone + * has neither: iOS has no file drag source, and gecko-for-iOS does not put + * pasted photos on the DOM clipboard. A plain file input is what reaches the + * system photo picker there. + * + * The input is visually hidden rather than display:none, since only the former + * is reliably clickable programmatically across engines. + */ +function AttachButton({ + disabled, + onFiles, +}: { + disabled: boolean; + onFiles: (files: FileList | null) => void; +}) { + const inputRef = useRef(null); + return ( + <> + { + const input = event.target as HTMLInputElement; + onFiles(input.files); + // clearing lets the same photo be picked again right after removing it + input.value = ""; + }} + /> + + + ); +} + +function AttachError({ + message, + onDismiss, +}: { + message: string; + onDismiss: () => void; +}) { + return ( +
+ {message} +
+ ); +} + function ImagePreviews({ images, onRemove, @@ -388,6 +494,9 @@ function OrdinaryInputBox({ onDragOver, onDragLeave, onDrop, + attachError, + dismissAttachError, + addFiles, } = useImageAttachments(); const internalRef = useRef(null); const textareaRef = inputRef ?? internalRef; @@ -568,6 +677,9 @@ function OrdinaryInputBox({ setImages((previous) => previous.filter((image) => image.id !== id)); }} /> + {attachError && ( + + )}