From 0336f009021b4010aa0d17a31a86bb3686c1e75a Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Wed, 4 Nov 2026 11:08:32 +0000 Subject: [PATCH 1/2] fix(web): attach a picture pasted into the message box The message box took no files from the clipboard, so a pasted screenshot did nothing. Pasted files attach like chosen ones; a clipboard picture, which always arrives as image.png, gets a stamped name so two pastes are not refused as duplicates. --- apps/web/src/pages/workspace/index.tsx | 8 +++ .../src/pages/workspace/pasted-files.test.ts | 29 +++++++++++ apps/web/src/pages/workspace/pasted-files.ts | 50 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 apps/web/src/pages/workspace/pasted-files.test.ts create mode 100644 apps/web/src/pages/workspace/pasted-files.ts diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index cbda015d..3f9c786c 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"; @@ -1391,6 +1392,13 @@ export function StageWorkspace({ )} { + 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..2a8abd91 --- /dev/null +++ b/apps/web/src/pages/workspace/pasted-files.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { pastedFiles, pastedName } from "./pasted-files.ts"; + +const at = new Date("2026-11-04T10:52:07.000Z"); + +test("a clipboard picture gets a stamped name; a named file keeps its own", () => { + const picture = new File([new Uint8Array([137, 80, 78, 71])], "image.png", { + type: "image/png", + }); + const report = new File(["hi"], "report.pdf", { type: "application/pdf" }); + const [first, second] = pastedFiles({ files: [picture, report] }, at); + expect(first?.name).toBe("pasted-2026-11-04-10-52-07.png"); + expect(first?.type).toBe("image/png"); + expect(second?.name).toBe("report.pdf"); +}); + +test("two pictures in one paste get different names, and a text paste yields none", () => { + const picture = () => + new File([new Uint8Array([1])], "image.png", { type: "image/png" }); + const names = pastedFiles({ files: [picture(), picture()] }, at).map( + (f) => f.name, + ); + expect(new Set(names).size).toBe(2); + expect(pastedFiles({ files: [] }, at)).toEqual([]); + expect(pastedFiles(null, at)).toEqual([]); + expect(pastedName("application/octet-stream", at)).toBe( + "pasted-2026-11-04-10-52-07", + ); +}); 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..2007484d --- /dev/null +++ b/apps/web/src/pages/workspace/pasted-files.ts @@ -0,0 +1,50 @@ +/** + * 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", +}; + +/** "pasted-2026-11-04-10-52-07.png": a name a person can tell apart from the last paste. */ +export function pastedName(type: string, at: Date): string { + const stamp = at + .toISOString() + .slice(0, 19) + .replace("T", "-") + .replaceAll(":", "-"); + const extension = EXTENSIONS[type]; + return extension === undefined + ? `pasted-${stamp}` + : `pasted-${stamp}.${extension}`; +} + +/** The files on the clipboard, each with a name of its own; empty when the paste is text. */ +export function pastedFiles( + clipboard: { files: ArrayLike } | null, + at: Date = new Date(), +): File[] { + if (clipboard === null) return []; + return Array.from(clipboard.files, (file, index) => { + if (!GENERIC_NAMES.has(file.name.toLowerCase())) return file; + const stamp = new Date(at.getTime() + index * 1000); + return new File([file], pastedName(file.type, stamp), { type: file.type }); + }); +} From f0917bef605e59c76cf6862205babb81f424080d Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Wed, 4 Nov 2026 11:16:32 +0000 Subject: [PATCH 2/2] fix(web): paste attaches only when attaching is offered, keeps text pastes, and never collides names The paste handler attached files while attaching was off or its limits were still loading, cancelled the paste either way, and took a picture an office app copies beside text in place of the text. Pasted pictures were named to the second, so two quick pastes collided. Paste follows the drop zone's condition, a paste with text is the browser's, and each pasted picture gets a counted name. --- apps/web/src/pages/workspace/index.tsx | 20 ++++++---- .../src/pages/workspace/pasted-files.test.ts | 38 +++++++++++-------- apps/web/src/pages/workspace/pasted-files.ts | 28 +++++++++----- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 3f9c786c..25210b63 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -1286,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(); diff --git a/apps/web/src/pages/workspace/pasted-files.test.ts b/apps/web/src/pages/workspace/pasted-files.test.ts index 2a8abd91..6805e6ed 100644 --- a/apps/web/src/pages/workspace/pasted-files.test.ts +++ b/apps/web/src/pages/workspace/pasted-files.test.ts @@ -2,28 +2,36 @@ import { expect, test } from "bun:test"; import { pastedFiles, pastedName } from "./pasted-files.ts"; const at = new Date("2026-11-04T10:52:07.000Z"); - -test("a clipboard picture gets a stamped name; a named file keeps its own", () => { - const picture = new File([new Uint8Array([137, 80, 78, 71])], "image.png", { +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({ files: [picture, report] }, at); - expect(first?.name).toBe("pasted-2026-11-04-10-52-07.png"); + 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("two pictures in one paste get different names, and a text paste yields none", () => { - const picture = () => - new File([new Uint8Array([1])], "image.png", { type: "image/png" }); - const names = pastedFiles({ files: [picture(), picture()] }, at).map( - (f) => f.name, - ); - expect(new Set(names).size).toBe(2); - expect(pastedFiles({ files: [] }, at)).toEqual([]); +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)).toBe( - "pasted-2026-11-04-10-52-07", + 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 index 2007484d..744e8f12 100644 --- a/apps/web/src/pages/workspace/pasted-files.ts +++ b/apps/web/src/pages/workspace/pasted-files.ts @@ -23,28 +23,36 @@ const EXTENSIONS: Readonly> = { "application/json": "json", }; -/** "pasted-2026-11-04-10-52-07.png": a name a person can tell apart from the last paste. */ -export function pastedName(type: string, at: Date): string { +// 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]; - return extension === undefined - ? `pasted-${stamp}` - : `pasted-${stamp}.${extension}`; + const base = `pasted-${stamp}-${String(serial)}`; + return extension === undefined ? base : `${base}.${extension}`; } -/** The files on the clipboard, each with a name of its own; empty when the paste is text. */ +/** + * 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 } | null, + clipboard: { files: ArrayLike; getData(type: string): string } | null, at: Date = new Date(), ): File[] { if (clipboard === null) return []; - return Array.from(clipboard.files, (file, index) => { + if (clipboard.getData("text/plain").trim() !== "") return []; + return Array.from(clipboard.files, (file) => { if (!GENERIC_NAMES.has(file.name.toLowerCase())) return file; - const stamp = new Date(at.getTime() + index * 1000); - return new File([file], pastedName(file.type, stamp), { type: file.type }); + pasted += 1; + return new File([file], pastedName(file.type, at, pasted), { + type: file.type, + }); }); }