From 9f39368e0bcf5e342b85bc423b73eb7d0d827039 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 25 Aug 2026 19:05:50 +0200 Subject: [PATCH 1/4] fix(web): copying a code block no longer pastes its fences Dragging a selection over the final linebreak of a fenced block ends the range after the closing pre, so the clipboard serializer no longer saw the selection as inside the block and re-fenced it. Pasting a shell command then carried stray backticks. A rendered fragment whose only visible content is one code block now copies as plain code, matching a selection that never left the pre. Prose or a second block alongside it still copies as fenced markdown. Model: Claude Opus 5. Harness: Claude Code. --- apps/web/src/markdown-clipboard.test.ts | 48 +++++++++++++++++++++ apps/web/src/markdown-clipboard.ts | 56 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 7265e8b60430..493eaa47c3b4 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -44,6 +44,14 @@ class FakeElement { hasAttribute(): boolean { return false; } + + closest(): FakeElement | null { + return null; + } + + querySelector(): FakeElement | null { + return null; + } } function asNode(element: FakeElement): Node { @@ -55,6 +63,21 @@ function shikiCodeLine(text: string): FakeElement { return new FakeElement("SPAN", ["line"]).append(token); } +/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */ +function renderedCodeBlock(lines: ReadonlyArray): FakeElement { + const code = new FakeElement("CODE"); + lines.forEach((line, index) => { + if (index > 0) code.append(new FakeText("\n")); + code.append(shikiCodeLine(line)); + }); + return new FakeElement("DIV", ["chat-markdown-codeblock"]).append( + new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append( + new FakeText("sh"), + ), + new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)), + ); +} + describe("serializeRenderedMarkdownFragment", () => { beforeEach(() => { vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); @@ -92,4 +115,29 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + + it("omits fences when a selection past the last line drags in the whole code block", () => { + // Dragging over the final newline ends the range after the pre, so the + // fragment carries the block plus the empty head of the next paragraph. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), + new FakeText("\n"), + new FakeElement("P").append(new FakeText("")), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN", + ); + }); + + it("still fences a code block copied alongside prose", () => { + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Run this:")), + renderedCodeBlock(["gh workflow run Deploy --ref main"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "Run this:\n\n```\ngh workflow run Deploy --ref main\n```", + ); + }); }); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188c..da44fe925c09 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -256,6 +256,60 @@ function serializeNode(node: Node): string { } } +/** + * Tracks whether a fragment carries exactly one code block and nothing else a + * reader would see. + */ +interface SoleCodeBlockScan { + pre: Element | null; + other: boolean; +} + +function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { + for (const child of node.childNodes) { + if (scan.other) return; + if (child.nodeType === Node.TEXT_NODE) { + if ((child.textContent ?? "").trim().length > 0) scan.other = true; + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const element = child as Element; + if (isSkippedElement(element)) continue; + if (element.hasAttribute("data-markdown-details")) { + scan.other = true; + continue; + } + const markdownCopy = element.getAttribute("data-markdown-copy"); + if (markdownCopy !== null) { + if (markdownCopy.trim().length > 0) scan.other = true; + continue; + } + if (element.tagName === "PRE") { + if (scan.pre) scan.other = true; + else scan.pre = element; + continue; + } + if (element.tagName === "IMG" || element.tagName === "HR") { + scan.other = true; + continue; + } + scanForSoleCodeBlock(element, scan); + } +} + +/** + * A drag that ends on a block's final newline pulls the closing `pre` into the + * range, so the fragment holds the whole block even though the user only + * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose + * only visible content is one code block copies as plain code, matching a + * selection that never left the `pre`. + */ +function soleCodeBlock(container: Node): Element | null { + const scan: SoleCodeBlockScan = { pre: null, other: false }; + scanForSoleCodeBlock(container, scan); + return scan.other ? null : scan.pre; +} + /** Collapses serializer spacing artifacts without touching fenced code content. */ function tidyMarkdown(markdown: string): string { return markdown @@ -268,6 +322,8 @@ function tidyMarkdown(markdown: string): string { } export function serializeRenderedMarkdownFragment(container: Node): string { + const codeBlock = soleCodeBlock(container); + if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, ""); return tidyMarkdown(serializeChildren(container)); } From 93aba5d1c65658c340a235fa2c7af35fae775675 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 22:35:03 +0200 Subject: [PATCH 2/4] fix(web): keep a file chip alongside a copied code block The sole-code-block scan tested isSkippedElement before data-markdown-copy, the reverse of serializeNode. A file chip renders as a button, a skipped tag, so the scan read it as invisible chrome and copied the fragment as bare code, dropping the file reference. Model: Claude Opus 5. Harness: Claude Code. --- apps/web/src/markdown-clipboard.test.ts | 23 +++++++++++++++++++---- apps/web/src/markdown-clipboard.ts | 5 ++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 493eaa47c3b4..14871942f0bd 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -22,6 +22,7 @@ class FakeElement { constructor( readonly tagName: string, private readonly classNames: ReadonlyArray = [], + private readonly attributes: Readonly> = {}, ) {} get localName(): string { @@ -37,12 +38,12 @@ class FakeElement { return this; } - getAttribute(): string | null { - return null; + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; } - hasAttribute(): boolean { - return false; + hasAttribute(name: string): boolean { + return name in this.attributes; } closest(): FakeElement | null { @@ -116,6 +117,20 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + it("keeps fences when a file chip sits alongside the code block", () => { + // The chip renders as a button, a skipped tag, but its data-markdown-copy + // still contributes markdown, so the block is not the only visible content. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["pnpm test"]), + new FakeText("\n"), + new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "```\npnpm test\n```\n\n`src/foo.ts`", + ); + }); + it("omits fences when a selection past the last line drags in the whole code block", () => { // Dragging over the final newline ends the range after the pre, so the // fragment carries the block plus the empty head of the next paragraph. diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index da44fe925c09..2e8393adc00e 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -274,7 +274,9 @@ function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { } if (child.nodeType !== Node.ELEMENT_NODE) continue; const element = child as Element; - if (isSkippedElement(element)) continue; + // Mirrors serializeNode's order: an element carrying markdown of its own + // still contributes it even when its tag is otherwise skipped, as a file + // chip rendered as a button does. if (element.hasAttribute("data-markdown-details")) { scan.other = true; continue; @@ -284,6 +286,7 @@ function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { if (markdownCopy.trim().length > 0) scan.other = true; continue; } + if (isSkippedElement(element)) continue; if (element.tagName === "PRE") { if (scan.pre) scan.other = true; else scan.pre = element; From 5a4734c0f83976ac5b224a080acf04425c5b0a00 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 22:40:17 +0200 Subject: [PATCH 3/4] fix(web): keep list markers alongside a copied code block serializeListItem emits a marker for every item, so an item with no visible text still carries content: a bare "- " or a task state. The sole-code-block scan recursed through such items without counting them, so a fragment holding an empty or checkbox-only item plus a code block copied as bare code and lost the marker. An item that does not hold the block now counts as other content. An item that wraps the block still does not, since a selection that never left the pre would drop its marker too. Model: Claude Opus 5. Harness: Claude Code. --- apps/web/src/markdown-clipboard.test.ts | 67 ++++++++++++++++++++++++- apps/web/src/markdown-clipboard.ts | 10 ++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 14871942f0bd..3e852a624a60 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -33,6 +33,10 @@ class FakeElement { return this.childNodes.map((child) => child.textContent).join(""); } + get children(): ReadonlyArray { + return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement); + } + append(...children: Array): this { this.childNodes.push(...children); return this; @@ -50,8 +54,28 @@ class FakeElement { return null; } - querySelector(): FakeElement | null { - return null; + /** Supports only the selectors markdown-clipboard actually asks for. */ + querySelector(selector: string): FakeElement | null { + const childOnly = selector.startsWith(":scope > "); + const target = childOnly ? selector.slice(":scope > ".length) : selector; + const matches = (element: FakeElement): boolean => { + if (target === 'input[type="checkbox"]') { + return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; + } + return element.tagName === target.toUpperCase(); + }; + const search = (parent: FakeElement): FakeElement | null => { + for (const child of parent.childNodes) { + if (!(child instanceof FakeElement)) continue; + if (matches(child)) return child; + if (!childOnly) { + const nested = search(child); + if (nested) return nested; + } + } + return null; + }; + return search(this); } } @@ -117,6 +141,45 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + it("keeps fences when a bare list item sits alongside the code block", () => { + // serializeListItem emits "- " for an item with no text, so the item is + // content the plain-code path would drop. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI")), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "-\n\n```\npnpm test\n```", + ); + }); + + it("keeps fences when a checkbox-only task item sits alongside the code block", () => { + // The checkbox is a skipped tag, so the item renders no text of its own, but + // it still carries the task state. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append( + new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })), + ), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "- [ ]\n\n```\npnpm test\n```", + ); + }); + + it("still drops fences for a code block that is the whole list item", () => { + // The item only wraps the block, so a selection that never left the pre + // would drop the marker too. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))), + new FakeText("\n"), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test"); + }); + it("keeps fences when a file chip sits alongside the code block", () => { // The chip renders as a button, a skipped tag, but its data-markdown-copy // still contributes markdown, so the block is not the only visible content. diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 2e8393adc00e..4a96c8b31d13 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -296,6 +296,16 @@ function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { scan.other = true; continue; } + if (element.tagName === "LI") { + // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item, + // so an item that does not hold the block carries content of its own even + // when it renders no text. An item that wraps the block is just the + // structure around it, and a pre-only selection would drop the marker too. + const preBeforeItem = scan.pre; + scanForSoleCodeBlock(element, scan); + if (!scan.other && scan.pre === preBeforeItem) scan.other = true; + continue; + } scanForSoleCodeBlock(element, scan); } } From 5a8cb5c68df0f519b39e64141d906886edaedf60 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 22:45:33 +0200 Subject: [PATCH 4/4] style(web): format the clipboard test assertions Model: Claude Opus 5. Harness: Claude Code. --- apps/web/src/markdown-clipboard.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 3e852a624a60..9f1219baeaf8 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -149,9 +149,7 @@ describe("serializeRenderedMarkdownFragment", () => { renderedCodeBlock(["pnpm test"]), ); - expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( - "-\n\n```\npnpm test\n```", - ); + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); }); it("keeps fences when a checkbox-only task item sits alongside the code block", () => {