Skip to content
Open
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
128 changes: 128 additions & 0 deletions web/src/components/InputBox.attach.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InputBox
onSend={() => {}}
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<HTMLInputElement>("input.input-file");
expect(input).not.toBeNull();
// a FileList stand-in: indexed access, length, item(), and iteration
const list: Record<string | number | symbol, unknown> = {
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<HTMLButtonElement>(".btn-attach");
const input = container.querySelector<HTMLInputElement>("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<HTMLInputElement>("input.input-file");
await selectFiles(container, [imageFile("one.jpg", "image/jpeg")]);
await act(async () => {
await flushReader();
});
expect(input!.value).toBe("");
});
});
120 changes: 120 additions & 0 deletions web/src/components/InputBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -151,6 +161,7 @@ function useImageAttachments(
() => imageEntry?.generation ?? 0,
);
const [isDragging, setIsDragging] = useState(false);
const [attachError, setAttachError] = useState<string | null>(null);
const enabledRef = useRef(enabled);
const resetTokenRef = useRef(resetToken);
const generationRef = useRef(imageEntry?.generation ?? 0);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -297,13 +320,96 @@ function useImageAttachments(
images: enabled && imagesGeneration === generationRef.current ? images : [],
setImages,
isDragging: enabled && isDragging,
attachError,
dismissAttachError: () => {
setAttachError(null);
},
addFiles,
onPaste,
onDragOver,
onDragLeave,
onDrop,
};
}

/** 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<HTMLInputElement>(null);
return (
<>
<input
ref={inputRef}
class="input-file"
type="file"
accept="image/*"
multiple
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
const input = event.target as HTMLInputElement;
onFiles(input.files);
// clearing lets the same photo be picked again right after removing it
input.value = "";
}}
/>
<button
key="attach"
class="btn btn-attach"
title="Attach images"
aria-label="Attach images"
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{/* drawn rather than typed: an emoji is a font glyph, so its artwork
changes per platform and its advance width adds side bearings that
no padding rule can reclaim. drawn upright, and the viewBox is
cropped to the artwork's width so the button is exactly as wide as
the clip */}
<svg
viewBox="5 0 14 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M17 5.75v10.85a5 5 0 0 1-10 0V5.75a3.35 3.35 0 0 1 6.7 0v10.85a1.65 1.65 0 0 1-3.3 0V6.6" />
</svg>
</button>
</>
);
}

function AttachError({
message,
onDismiss,
}: {
message: string;
onDismiss: () => void;
}) {
return (
<div class="attach-error" role="status" onClick={onDismiss}>
{message}
</div>
);
}

function ImagePreviews({
images,
onRemove,
Expand Down Expand Up @@ -388,6 +494,9 @@ function OrdinaryInputBox({
onDragOver,
onDragLeave,
onDrop,
attachError,
dismissAttachError,
addFiles,
} = useImageAttachments();
const internalRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = inputRef ?? internalRef;
Expand Down Expand Up @@ -568,6 +677,9 @@ function OrdinaryInputBox({
setImages((previous) => previous.filter((image) => image.id !== id));
}}
/>
{attachError && (
<AttachError message={attachError} onDismiss={dismissAttachError} />
)}
<textarea
key="textarea"
ref={textareaRef}
Expand All @@ -585,6 +697,7 @@ function OrdinaryInputBox({
disabled={disabled}
rows={1}
/>
<AttachButton disabled={disabled || !!stdinClosed} onFiles={addFiles} />
{isProcessing && (
<button key="stop" class="btn btn-stop" onClick={onInterrupt}>
Stop
Expand Down Expand Up @@ -630,6 +743,9 @@ function ControlledInputBox({
onDragOver,
onDragLeave,
onDrop,
attachError,
dismissAttachError,
addFiles,
} = useImageAttachments(!disabled, composerResetToken, imageStore, imageKey);
const internalRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = inputRef ?? internalRef;
Expand Down Expand Up @@ -729,6 +845,9 @@ function ControlledInputBox({
setImages((previous) => previous.filter((image) => image.id !== id));
}}
/>
{attachError && (
<AttachError message={attachError} onDismiss={dismissAttachError} />
)}
<textarea
key="textarea"
ref={textareaRef}
Expand All @@ -744,6 +863,7 @@ function ControlledInputBox({
disabled={disabled}
rows={1}
/>
<AttachButton disabled={disabled || !!stdinClosed} onFiles={addFiles} />
{isProcessing && (
<button key="stop" class="btn btn-stop" onClick={onInterrupt}>
Stop
Expand Down
53 changes: 53 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,56 @@ body,
overflow-anchor: none;
}

/* Attach button: visually hidden input, since display:none is not reliably
clickable from script across engines */
.input-file {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}

/* the glyph is the control: no chrome, no inner spacing, sized against the
send button beside it. .btn.btn-attach rather than .btn-attach, since the
generic .btn rule is declared later in this file and would otherwise win on
order alone */
.btn.btn-attach {
background: none;
border: none;
border-radius: 0;
padding: 0;
line-height: 0;
color: var(--text-dim);
opacity: 0.8;
}

.btn.btn-attach:hover:not(:disabled) {
opacity: 1;
}

.btn.btn-attach:disabled {
opacity: 0.3;
}

/* an svg has no side bearings, so the box is exactly the icon: the same
height as the send button beside it, width from the viewBox ratio */
.btn.btn-attach svg {
width: auto;
height: var(--composer-btn-h);
display: block;
}

.input-box .btn-send {
height: var(--composer-btn-h);
}

.attach-error {
padding: 6px 10px;
font-size: 13px;
color: var(--text-dim);
cursor: pointer;
}
/* Message wrapper (action buttons) */
.message-wrapper {
position: relative;
Expand Down Expand Up @@ -1620,6 +1670,9 @@ body,

/* Input box */
.input-box {
/* one height for the send button and the attach icon, so they always match
exactly; 36px is the send button's natural height at this padding */
--composer-btn-h: 36px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
Expand Down