diff --git a/apps/web/src/lib/assistantTextSelection.test.ts b/apps/web/src/lib/assistantTextSelection.test.ts index 31f00225588a..f4725af73ad1 100644 --- a/apps/web/src/lib/assistantTextSelection.test.ts +++ b/apps/web/src/lib/assistantTextSelection.test.ts @@ -56,6 +56,13 @@ class SelectionNode { get nodeType() { return this.tagName === "#text" ? 3 : 1; } + get ownerDocument() { + // `user-select: none` inherits, like Chromium's computed style. + const getComputedStyle = (element: SelectionNode) => ({ + userSelect: element.closest("[data-select-none]") ? "none" : "auto", + }); + return { defaultView: { getComputedStyle } }; + } get length() { return this.data.length; } @@ -259,6 +266,22 @@ describe("captureAssistantTextSelection", () => { expect(capture(source, nativeSelection([quote, 0], [timestampText, 1]))).toBeNull(); }); + it("captures a triple-clicked response that ends before a tool row and the next response", () => { + const quote = textNode("Recording your answers in the session note:"); + const source = assistantSource(new SelectionNode("P").append(quote)); + const nextParagraph = new SelectionNode("P").append(textNode("1. What it means.")); + const viewport = new SelectionNode("MAIN").append( + source, + new SelectionNode("DIV", "", { "data-select-none": "" }).append(textNode("echo ok")), + new SelectionNode("H3", "", { "data-select-none": "" }).append(textNode("T3 Code")), + assistantSource(nextParagraph), + ); + + expect(capture(viewport, nativeSelection([quote, 0], [nextParagraph, 0]))?.selector).toEqual( + selector(quote.data), + ); + }); + it("does not attribute an empty starting endpoint to the previous response", () => { const previous = textNode("Previous response."); const quote = textNode("Selected response."); diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index f118555f3eac..1f388b2e14f9 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -189,13 +189,21 @@ function isUsableRange(root: HTMLElement, range: Range): boolean { return true; } +function isUnselectable(text: Text): boolean { + const element = text.parentElement; + const view = element?.ownerDocument?.defaultView; + return !!element && !!view && view.getComputedStyle(element).userSelect === "none"; +} + function selectedTextBoundary(range: Range, node: Node, last: boolean): Text | null { if (!range.intersectsNode(node)) return null; if (node.nodeType === 3) { const text = node as Text; const start = node === range.startContainer ? range.startOffset : 0; const end = node === range.endContainer ? range.endOffset : text.length; - return start < end ? text : null; + // Triple-click ranges run through unselectable rows (tool calls, timestamps, + // author headings) into the next response; that text is not selected. + return start < end && !isUnselectable(text) ? text : null; } for ( let child = last ? node.lastChild : node.firstChild;