Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions apps/web/src/pages/workspace/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = (
<div
className="composer"
Expand Down Expand Up @@ -1342,13 +1351,7 @@ export function StageWorkspace({
) : null}
<AttachmentDropZone
onFiles={addFiles}
disabled={
limits === undefined ||
limits.mimeTypes.length === 0 ||
sending ||
approvalBusy ||
providerMissing
}
disabled={!attaching}
label={wording.conversation.dropFiles}
>
<Dictated
Expand Down Expand Up @@ -1391,6 +1394,15 @@ export function StageWorkspace({
)}
<PromptInputTextarea
ref={composerRef}
// A picture on the clipboard attaches like a chosen file, while attaching is
// offered at all; text, and anything else, pastes as the browser would.
onPaste={(event) => {
if (!attaching) return;
const files = pastedFiles(event.clipboardData);
if (files.length === 0) return;
Comment on lines +1401 to +1402

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · A copied file that also puts text on the clipboard is not attached

The handler leaves the paste alone whenever pastedFiles returns nothing. pastedFiles (in pasted-files.ts, not part of this diff) returns nothing whenever the clipboard carries any non-blank text/plain. When you copy a file in a file manager, the browser usually also puts the file name on the clipboard as text/plain. So pasting a real file inserts its name instead of attaching it, which goes against the PR's goal of attaching any readable file.

Copy a PDF or image file in macOS Finder and paste it into the message box in Chrome.

Resolve this comment. Triage will not finish while it is open.

event.preventDefault();
Comment on lines +1402 to +1403

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Mixed text-and-image clipboard drops the text

Whenever the clipboard carries any file, preventDefault() runs, even if it also carries text. Office apps and some browsers put a picture next to the text when you copy, so a paste that should insert text attaches an image and loses the text.

Copy cells from Excel or text from Word, which also puts an image/png on the clipboard, then paste into the message box.

Resolve this comment. Triage will not finish while it is open.

addFiles(files);
Comment on lines +1402 to +1404

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Paste is silently swallowed while limits are still loading

The handler calls preventDefault() before addFiles. addFiles returns early when limits is undefined, so the paste is cancelled and nothing appears: no attachment and no message. This is the same silent failure the PR is meant to fix.

Paste an image before the attachment limits have loaded.

Resolve this comment. Triage will not finish while it is open.

}}
Comment on lines +1399 to +1405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Paste attaches files while attaching is turned off

When the provider is missing or no MIME types are allowed, the drop zone is disabled and the paperclip is hidden. The new paste handler checks neither condition, so pasting still attaches files.

Paste a screenshot when providerMissing is true or limits.mimeTypes is empty.

Resolve this comment. Triage will not finish while it is open.

placeholder={
providerMissing
? wording.conversation.enableAiFirst
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/pages/workspace/pasted-files.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
58 changes: 58 additions & 0 deletions apps/web/src/pages/workspace/pasted-files.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
"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(":", "-");
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Pasted names are only unique to the second, so close pastes collide as duplicates

The name is built from the timestamp cut down to whole seconds. Two pastes of a clipboard picture within the same second get the same pasted-…png name. Within one paste, a later file is pushed forward by index * 1000 ms, so its name can also match the first file of a paste made a second later. The attachment list then rejects the second file as already attached, which is the bug this PR set out to fix.

Press Ctrl+V twice quickly with a screenshot on the clipboard, or paste two images and paste again a second later.

Resolve this comment. Triage will not finish while it is open.

const extension = EXTENSIONS[type];
Comment on lines +32 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 · Name stamp uses UTC instead of the person's local time

toISOString() always gives UTC. A stamp meant to show "the time for the person" is wrong by their UTC offset; outside UTC it can even show a different date from the one they pasted on.

Paste a screenshot from a browser set to any time zone other than UTC.

Resolve this comment. Triage will not finish while it is open.

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<File>; 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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Any text on the clipboard throws away the pasted files

If the clipboard has any non-blank text/plain, the code skips every file. Copying a file in a file manager (Finder, Explorer) or using "Copy Image" in some browsers puts the file name or image URL on the clipboard as text/plain next to the file. The file is then silently dropped: the paste inserts a name or URL instead of attaching anything, which is the silent failure #151 is meant to fix.

Copy report.pdf in macOS Finder and paste it into the message box: the clipboard holds the file plus text/plain "report.pdf".

Resolve this comment. Triage will not finish while it is open.

if (!GENERIC_NAMES.has(file.name.toLowerCase())) return file;
pasted += 1;
return new File([file], pastedName(file.type, at, pasted), {
type: file.type,
});
});
}