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,
+ });
+ });
+}