From ccd2921d464afc74b255a797232a24efe19d473e Mon Sep 17 00:00:00 2001 From: Jindrich Rehacek Date: Mon, 24 Aug 2026 02:28:33 +0200 Subject: [PATCH 1/2] fix(web): copy final code block lines as plain text --- apps/web/src/markdown-clipboard.test.ts | 59 ++++++++++++++++++++++++- apps/web/src/markdown-clipboard.ts | 30 ++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 7265e8b60430..161a526b363b 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; +import { plainTextForCodeSelection, serializeRenderedMarkdownFragment } from "./markdown-clipboard"; const TEXT_NODE = 3; const ELEMENT_NODE = 1; @@ -93,3 +93,60 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); }); + +function rangeNode(closestPre: Element | null): Node { + return { + nodeType: ELEMENT_NODE, + closest: () => closestPre, + } as unknown as Node; +} + +function codeSelectionRange({ + commonAncestorPre, + trailingText, +}: { + commonAncestorPre: Element | null; + trailingText: string; +}): Range { + return { + commonAncestorContainer: rangeNode(commonAncestorPre), + startContainer: rangeNode({} as Element), + cloneRange: () => + ({ + setStartAfter: vi.fn(), + toString: () => trailingText, + }) as unknown as Range, + toString: () => "sudo dnf remove alacritty", + } as unknown as Range; +} + +describe("plainTextForCodeSelection", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps a selection contained by pre plain", () => { + const range = codeSelectionRange({ commonAncestorPre: {} as Element, trailingText: "unused" }); + + expect(plainTextForCodeSelection(range)).toBe("sudo dnf remove alacritty"); + }); + + it("keeps a final-line selection plain when only its trailing boundary leaves pre", () => { + const range = codeSelectionRange({ commonAncestorPre: null, trailingText: "\n" }); + + expect(plainTextForCodeSelection(range)).toBe("sudo dnf remove alacritty"); + }); + + it("preserves markdown serialization when a code selection continues into prose", () => { + const range = codeSelectionRange({ + commonAncestorPre: null, + trailingText: "The following paragraph", + }); + + expect(plainTextForCodeSelection(range)).toBeNull(); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188c..93d5d7964317 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -309,6 +309,28 @@ function sanitizedHtmlFrom(container: Element): string { return `${container.innerHTML}`; } +function elementForNode(node: Node): Element | null { + return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; +} + +/** + * Returns raw code for selections contained by `pre`, including final-line + * triple-click ranges whose trailing boundary leaves the block without + * selecting any following text. + */ +export function plainTextForCodeSelection(range: Range): string | null { + if (elementForNode(range.commonAncestorContainer)?.closest("pre")) { + return range.toString(); + } + + const startPre = elementForNode(range.startContainer)?.closest("pre"); + if (!startPre) return null; + + const trailingRange = range.cloneRange(); + trailingRange.setStartAfter(startPre); + return trailingRange.toString().trim() ? null : range.toString(); +} + export function chatMarkdownClipboardPayload( selection: Selection, ): MarkdownClipboardPayload | null { @@ -319,11 +341,9 @@ export function chatMarkdownClipboardPayload( if (range.collapsed) continue; const container = document.createElement("div"); container.appendChild(range.cloneContents()); - const ancestor = range.commonAncestorContainer; - const ancestorElement = - ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; - if (ancestorElement?.closest("pre")) { - const text = range.toString(); + const plainCodeText = plainTextForCodeSelection(range); + if (plainCodeText !== null) { + const text = plainCodeText; if (text) { texts.push(text); htmls.push(sanitizedHtmlFrom(container)); From 88516f96be988e480761c9262ad049fd045f9317 Mon Sep 17 00:00:00 2001 From: Jindrich Rehacek Date: Mon, 24 Aug 2026 02:41:14 +0200 Subject: [PATCH 2/2] fix(web): preserve textless trailing markdown selections --- apps/web/src/markdown-clipboard.test.ts | 16 +++++++++++++++- apps/web/src/markdown-clipboard.ts | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 161a526b363b..edfb7e16d309 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -104,17 +104,21 @@ function rangeNode(closestPre: Element | null): Node { function codeSelectionRange({ commonAncestorPre, trailingText, + trailingElement, }: { commonAncestorPre: Element | null; trailingText: string; + trailingElement?: FakeElement; }): Range { + const trailingContent = + trailingElement ?? new FakeElement("DIV").append(new FakeText(trailingText)); return { commonAncestorContainer: rangeNode(commonAncestorPre), startContainer: rangeNode({} as Element), cloneRange: () => ({ setStartAfter: vi.fn(), - toString: () => trailingText, + cloneContents: () => asNode(trailingContent), }) as unknown as Range, toString: () => "sudo dnf remove alacritty", } as unknown as Range; @@ -149,4 +153,14 @@ describe("plainTextForCodeSelection", () => { expect(plainTextForCodeSelection(range)).toBeNull(); }); + + it("preserves markdown serialization for selected trailing nodes without text", () => { + const range = codeSelectionRange({ + commonAncestorPre: null, + trailingText: "", + trailingElement: new FakeElement("DIV").append(new FakeElement("HR")), + }); + + expect(plainTextForCodeSelection(range)).toBeNull(); + }); }); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 93d5d7964317..69fc8aed50ee 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -328,7 +328,7 @@ export function plainTextForCodeSelection(range: Range): string | null { const trailingRange = range.cloneRange(); trailingRange.setStartAfter(startPre); - return trailingRange.toString().trim() ? null : range.toString(); + return serializeRenderedMarkdownFragment(trailingRange.cloneContents()) ? null : range.toString(); } export function chatMarkdownClipboardPayload(