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.
This commit is contained in:
Armin Ronacher 2026-11-04 18:39:15 +01:00
parent 386db6130e
commit 707888ece6
3 changed files with 65 additions and 3 deletions

View file

@ -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

View file

@ -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<typeof useComposerState>): 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()}

View file

@ -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<boolean>(`paste('', ['image.png', ''])`), true);
await browser.wait(`document.querySelectorAll('.attachment img').length===2`);
assert.deepEqual(
await browser.evaluate<string[]>(`[...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<boolean>(`paste('a1\\tb1', ['cells.png'])`), false);
assert.equal(await browser.evaluate<boolean>(`paste('just text', [])`), false);
assert.equal(await browser.evaluate<number>(`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"],
);
});
});