From 707888ece685778881c40d232880c5a0b93a12f1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 4 Nov 2026 18:39:15 +0100 Subject: [PATCH] feat(p2): paste images into the composer A paste with only images (a screenshot) attaches them through the composer's attach flow; a paste with plain text stays a text paste. --- apps/p2/docs/p2-notes.md | 4 ++- apps/p2/plugins/chat/web.tsx | 27 ++++++++++++++++- apps/p2/test/p2/ui-chat-controls.test.ts | 37 +++++++++++++++++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index a681d4c9a..df0dcdb8f 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -807,7 +807,9 @@ translated phone drawer and intercept chat taps. (server local) saves immutable snapshots for producers (send_file); `GET|HEAD /download?session=&id=` serves them to the owner and the session's guests (no `Sec-Fetch-Site: cross-site`; ranges for audio/video). Owns local `Uploads`, `Downloads` and `filePreview` implementations. Web: the composer's - attach button and drag/drop upload files at send time, including a new session's first prompt. + attach button, drag/drop and pasted images upload files at send time, including a new session's first prompt. A + paste with plain text stays a text paste (Office apps add a picture of the copied text, Chrome on macOS a Finder + file's icon); a paste with only images attaches them. - **File index** (`plugins/file-index`): `FileIndex({ session, conversation })` (participants, as `Workspace`) answers `@` completion (`complete`, ranked like pi's `@`) and Tab completion (`list`, one directory) for the TUI and the web composer. A worker-local cache per conversation (`FileIndexCache`) builds from `WorkspaceBackend`'s `files` query diff --git a/apps/p2/plugins/chat/web.tsx b/apps/p2/plugins/chat/web.tsx index ce5dbbfe0..8ce40c502 100644 --- a/apps/p2/plugins/chat/web.tsx +++ b/apps/p2/plugins/chat/web.tsx @@ -127,13 +127,31 @@ function refused(error: unknown): boolean { ); } -/** Picked files: shown above the textarea, removable; the attach button adds more. */ +/** Picked files: shown above the textarea, removable; the attach button and pasted images add more. */ type Attaching = { readonly files: readonly PickedFile[]; readonly add: (files: File[]) => void; readonly remove: (file: PickedFile) => void; }; +/** + * The images a paste attaches, or none to leave the paste to the textarea. Plain text wins: Office apps put a rendered + * picture of the copied cells beside the text, and Chrome on macOS pastes a Finder file as its icon beside its name. + */ +function pastedImages(data: DataTransfer): File[] { + if (data.getData("text/plain") !== "") return []; + return [...data.items] + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file) => file !== null) + .map((file) => + // The server infers an upload's image type from its extension; a nameless paste gets one from its type. + /\.[a-z0-9]+$/i.test(file.name) + ? file + : new File([file], `pasted-image.${file.type.slice(6).replace("jpeg", "jpg")}`, { type: file.type }), + ); +} + /** The composer's attachments from its state. */ const attachingOf = ({ files, setFiles, addFiles }: ReturnType): Attaching => ({ files, @@ -263,6 +281,13 @@ function Composer({ completion?.update(); }} onClick={() => completion?.update()} + onPaste={(event) => { + if (!attaching) return; + const images = pastedImages(event.clipboardData); + if (!images.length) return; + event.preventDefault(); + attaching.add(images); + }} onFocus={() => completion?.onFocus()} onBlur={() => completion?.onBlur()} onCompositionStart={() => completion?.onCompositionStart()} diff --git a/apps/p2/test/p2/ui-chat-controls.test.ts b/apps/p2/test/p2/ui-chat-controls.test.ts index a4ffa1b7a..34e269464 100644 --- a/apps/p2/test/p2/ui-chat-controls.test.ts +++ b/apps/p2/test/p2/ui-chat-controls.test.ts @@ -1,7 +1,7 @@ // Main-view controls exercise real browser events against fake providers and disposable data. import assert from "node:assert/strict"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; import { BACKGROUND_CONTEXT } from "@p2/app/v1"; @@ -127,4 +127,39 @@ describe("chat control parity", { skip: !browserTests }, () => { ); await browser.click("button[aria-label=Stop]"); }); + it("attaches pasted images like picked files and leaves text pastes to the textarea", async () => { + const pasted = ( + await ( + await holdIn(fixture.browser(), Sessions, {}, "owner") + ).create({ cwd: fixture.workspace, title: "Paste" }, BACKGROUND_CONTEXT) + ).id; + await browser.metrics(1280, 900); + await browser.open(`http://127.0.0.1:${fixture.server.port}/s/${pasted}`); + await browser.wait(`document.querySelector('.chat .composer textarea')`); + // A 1x1 PNG; `paste(text, names)` dispatches a paste of that text and PNGs and returns whether it was cancelled. + await browser.evaluate( + `window.paste=(text,names)=>{const png=Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),c=>c.charCodeAt(0));const d=new DataTransfer();if(text)d.setData('text/plain',text);for(const name of names)d.items.add(new File([png],name,{type:'image/png'}));return !document.querySelector('.composer textarea').dispatchEvent(new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:d}))}`, + ); + assert.equal(await browser.evaluate(`paste('', ['image.png', ''])`), true); + await browser.wait(`document.querySelectorAll('.attachment img').length===2`); + assert.deepEqual( + await browser.evaluate(`[...document.querySelectorAll('.attachment-name')].map(e=>e.textContent)`), + ["image.png", "pasted-image.png"], + ); + // Text with a picture of it (an Office copy) and plain text keep the default paste and attach nothing. + assert.equal(await browser.evaluate(`paste('a1\\tb1', ['cells.png'])`), false); + assert.equal(await browser.evaluate(`paste('just text', [])`), false); + assert.equal(await browser.evaluate(`document.querySelectorAll('.attachment').length`), 2); + await browser.evaluate(input(".composer textarea", "with screenshots")); + await browser.click("button[aria-label=Send]"); + await browser.wait( + `[...document.querySelectorAll('.bubble-files span')].map(e=>e.textContent).join()==='image.png,pasted-image.png' && !document.querySelector('.attachment')`, + ); + assert.deepEqual( + readdirSync(join(fixture.dataDir, "uploads", pasted)) + .map((file) => file.slice(37)) + .sort(), + ["image.png", "pasted-image.png"], + ); + }); });