diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index cbda015d..25210b63 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -49,6 +49,7 @@ import { type DispatchRoom, } from "./dispatch-card.tsx"; import { earlierDocumentRole } from "./earlier-document.ts"; +import { pastedFiles } from "./pasted-files.ts"; import { ArtifactPane, LIVE_TAB } from "./artifact-pane.tsx"; import "./persistent-workspace.css"; import { ProductApiError, type ProjectArtifact } from "@corbits-builder/client"; @@ -1285,6 +1286,14 @@ export function StageWorkspace({ ); } } else { + // Files can be attached while the hub offers kinds it reads, the provider is set and nothing is + // mid-send; the drop zone, the paperclip and a paste all follow this. + const attaching = + limits !== undefined && + limits.mimeTypes.length > 0 && + !sending && + !approvalBusy && + !providerMissing; dock = (
{ + if (!attaching) return; + const files = pastedFiles(event.clipboardData); + if (files.length === 0) return; + event.preventDefault(); + addFiles(files); + }} placeholder={ providerMissing ? wording.conversation.enableAiFirst diff --git a/apps/web/src/pages/workspace/pasted-files.test.ts b/apps/web/src/pages/workspace/pasted-files.test.ts new file mode 100644 index 00000000..6805e6ed --- /dev/null +++ b/apps/web/src/pages/workspace/pasted-files.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { pastedFiles, pastedName } from "./pasted-files.ts"; + +const at = new Date("2026-11-04T10:52:07.000Z"); +const picture = () => + new File([new Uint8Array([137, 80, 78, 71])], "image.png", { + type: "image/png", + }); +const clipboard = (files: File[], text = "") => ({ + files, + getData: (type: string) => (type === "text/plain" ? text : ""), +}); + +test("a clipboard picture gets a stamped name; a named file keeps its own", () => { + const report = new File(["hi"], "report.pdf", { type: "application/pdf" }); + const [first, second] = pastedFiles(clipboard([picture(), report]), at); + expect(first?.name).toMatch(/^pasted-2026-11-04-10-52-07-\d+\.png$/); + expect(first?.type).toBe("image/png"); + expect(second?.name).toBe("report.pdf"); +}); + +test("every pasted picture gets its own name, even within one second", () => { + const names = [ + ...pastedFiles(clipboard([picture(), picture()]), at), + ...pastedFiles(clipboard([picture()]), at), + ].map((file) => file.name); + expect(new Set(names).size).toBe(3); +}); + +test("a paste that carries text is left to the browser, and an empty one yields nothing", () => { + expect(pastedFiles(clipboard([picture()], "A1\tB1"), at)).toEqual([]); + expect(pastedFiles(clipboard([]), at)).toEqual([]); + expect(pastedFiles(null, at)).toEqual([]); + expect(pastedName("application/octet-stream", at, 7)).toBe( + "pasted-2026-11-04-10-52-07-7", + ); +}); diff --git a/apps/web/src/pages/workspace/pasted-files.ts b/apps/web/src/pages/workspace/pasted-files.ts new file mode 100644 index 00000000..744e8f12 --- /dev/null +++ b/apps/web/src/pages/workspace/pasted-files.ts @@ -0,0 +1,58 @@ +/** + * Files pasted into the message box, named so each can be attached: a picture from the clipboard + * arrives as "image.png" every time, which the attachment list would refuse as a duplicate. + */ +const GENERIC_NAMES = new Set([ + "", + "image.png", + "image.jpg", + "image.jpeg", + "image.gif", + "image.webp", +]); + +const EXTENSIONS: Readonly> = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "application/pdf": "pdf", + "text/plain": "txt", + "text/markdown": "md", + "text/csv": "csv", + "application/json": "json", +}; + +// Counts up for the page's life, so two pastes in one second still get different names. +let pasted = 0; + +/** "pasted-2026-11-04-10-52-07-3.png": the time for the person, the count so no two collide. */ +export function pastedName(type: string, at: Date, serial: number): string { + const stamp = at + .toISOString() + .slice(0, 19) + .replace("T", "-") + .replaceAll(":", "-"); + const extension = EXTENSIONS[type]; + const base = `pasted-${stamp}-${String(serial)}`; + return extension === undefined ? base : `${base}.${extension}`; +} + +/** + * The files on the clipboard, each with a name of its own, or none when the paste carries text: + * an office app copies a picture of the cells beside the text, and the text is what was meant. + */ +export function pastedFiles( + clipboard: { files: ArrayLike; getData(type: string): string } | null, + at: Date = new Date(), +): File[] { + if (clipboard === null) return []; + if (clipboard.getData("text/plain").trim() !== "") return []; + return Array.from(clipboard.files, (file) => { + if (!GENERIC_NAMES.has(file.name.toLowerCase())) return file; + pasted += 1; + return new File([file], pastedName(file.type, at, pasted), { + type: file.type, + }); + }); +}