Skip to content
Draft
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
45 changes: 45 additions & 0 deletions site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,51 @@ export const CtrlShiftVBypassesAttachmentCollapse: Story = {
},
};

// A large SVG source (>= isLargePaste thresholds) that ends up as a single
// long string when pasted. Long enough to trigger the large-paste heuristic
// on character count alone.
const largeSvgPaste = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">',
Array.from(
{ length: 20 },
(_, i) =>
` <rect x="${i * 5}" y="${i * 5}" width="20" height="20" fill="hsl(${i * 15}, 80%, 60%)" />`,
).join("\n"),
"</svg>",
].join("\n");

export const LargeSVGPasteRemainsInline: Story = {
args: {
attachments: [],
onAttach: fn(),
onRemoveAttachment: fn(),
},
parameters: {
pixel: { exclude: true },
},
play: async ({ canvasElement, args }) => {
const target = getPasteTarget(canvasElement);
await waitFor(() => {
expect(target.getAttribute("contenteditable")).toBe("true");
});
target.focus();

dispatchPasteWithText(target, largeSvgPaste);

// SVG must land inline in the editor even though it exceeds the
// isLargePaste thresholds. The server rejects SVG uploads, so
// converting the paste into an attachment would surface
// "Unsupported file type." instead of inserting the text.
await waitFor(() => {
expect(target.textContent).toContain("<svg");
expect(target.textContent).toContain("</svg>");
});

expect(args.onAttach).not.toHaveBeenCalled();
},
};

// ── MCP server fixtures ────────────────────────────────────────

const now = "2026-03-19T12:00:00.000Z";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
createPasteFile,
getPasteDataTransfer,
getPastedPlainText,
hasSVGRootElement,
isLargePaste,
type PasteCommandEvent,
} from "./pasteHelpers";
Expand Down Expand Up @@ -211,7 +212,8 @@ const PasteSanitizationPlugin: FC<{
!isPlainTextPaste &&
allowTextAttachmentPaste &&
onFilePaste &&
isLargePaste(text)
isLargePaste(text) &&
!hasSVGRootElement(text)
) {
event.preventDefault();
onFilePaste(createPasteFile(text));
Expand Down Expand Up @@ -245,11 +247,16 @@ const PasteSanitizationPlugin: FC<{
// Convert large pastes to file attachments, but
// only for normal Cmd+V. Cmd+Shift+V is the
// user's explicit "paste inline" escape hatch.
// SVG XML is excluded because the server rejects
// SVG uploads (they can carry active script) and
// we would otherwise surface "Unsupported file
// type." for pasted SVG source.
if (
!isPlainTextPaste &&
allowTextAttachmentPaste &&
onFilePaste &&
isLargePaste(text)
isLargePaste(text) &&
!hasSVGRootElement(text)
) {
event.preventDefault();
onFilePaste(createPasteFile(text));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createPasteFile,
getPasteDataTransfer,
getPastedPlainText,
hasSVGRootElement,
isLargePaste,
} from "./pasteHelpers";

Expand Down Expand Up @@ -160,3 +161,98 @@ describe("createPasteFile", () => {
expect(content).toBe(text);
});
});

describe("hasSVGRootElement", () => {
it("returns false for empty text", () => {
expect(hasSVGRootElement("")).toBe(false);
});

it("returns false for pure whitespace", () => {
expect(hasSVGRootElement(" \n\t ")).toBe(false);
});

it("detects a bare <svg> element", () => {
expect(hasSVGRootElement("<svg></svg>")).toBe(true);
});

it("detects a self-closing <svg/> element", () => {
expect(hasSVGRootElement("<svg/>")).toBe(true);
});

it("detects <svg> with attributes", () => {
expect(
hasSVGRootElement(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>',
),
).toBe(true);
});

it("is case-insensitive on the tag name", () => {
expect(hasSVGRootElement("<SVG></SVG>")).toBe(true);
expect(hasSVGRootElement("<SvG></SvG>")).toBe(true);
});

it("detects <svg> after an XML declaration", () => {
expect(
hasSVGRootElement(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>',
),
).toBe(true);
});

it("detects <svg> after an XML declaration with whitespace", () => {
expect(
hasSVGRootElement('<?xml version="1.0" encoding="UTF-8"?>\n<svg></svg>'),
).toBe(true);
});

it("detects <svg> after a DOCTYPE directive", () => {
expect(
hasSVGRootElement(
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg></svg>',
),
).toBe(true);
});

it("detects <svg> after XML comments", () => {
expect(hasSVGRootElement("<!-- created by hand -->\n<svg></svg>")).toBe(
true,
);
});

it("skips a UTF-8 BOM before <svg>", () => {
expect(hasSVGRootElement("\uFEFF<svg></svg>")).toBe(true);
});

it("returns false for HTML documents", () => {
expect(hasSVGRootElement("<html><body>not svg</body></html>")).toBe(false);
});

it("returns false for markdown that mentions svg later", () => {
expect(hasSVGRootElement('# SVG Example\n<svg width="100">...</svg>')).toBe(
false,
);
});

it("returns false for CSV rows containing svg fragments", () => {
expect(hasSVGRootElement("name,icon\nlogo,<svg><rect/></svg>\n")).toBe(
false,
);
});

it("returns false for lookalike tag names such as <svgx>", () => {
expect(hasSVGRootElement("<svgx></svgx>")).toBe(false);
});

it("returns false for plain text starting with '<'", () => {
expect(hasSVGRootElement("< less than sign followed by text")).toBe(false);
});

it("returns false for unterminated XML processing instructions", () => {
expect(hasSVGRootElement("<?xml version='1.0'")).toBe(false);
});

it("returns false for unterminated XML comments", () => {
expect(hasSVGRootElement("<!-- open comment")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ export function getPastedPlainText(
return "data" in event && typeof event.data === "string" ? event.data : "";
}

/**
* Detects whether the pasted text parses as an SVG document with an
* `<svg>` root element.
*
* The server refuses SVG uploads and probes for an SVG root regardless of
* the declared MIME type. We can't lean on the clipboard MIME here
* because SVG source copied from a text editor arrives as `text/plain`,
* so we ask the browser's XML parser to classify the content for us.
*
* The check is deliberately lenient: a false positive just leaves an
* XML-ish paste inline, which is harmless, whereas a false negative
* reproduces the "Unsupported file type." bug the server surfaces.
*/
export function hasSVGRootElement(text: string): boolean {
const doc = new DOMParser().parseFromString(text, "image/svg+xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
return false;
}
return doc.documentElement?.tagName.toLowerCase() === "svg";
}

/**
* Determines whether a pasted text should be treated as a file
* attachment rather than inline editor content.
Expand Down
Loading