authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 01:23:58-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 01:23:58-03:00
logd02e292610ea5eff171d388211ba3ede2f0467a0
treed47ff16cfdf0143b7e553c0fa402f65524298c1b
parent4ea735614bb8e80ef0f41bf01dff362ecc705d92
signature Signed by PGP key B5690EEEBB952194

fix(web): cite works on responses that end before a tool call (#17713)


2 files changed, 32 insertions(+), 1 deletions(-)

apps/web/src/lib/assistantTextSelection.test.ts+23
...@@ -56,6 +56,13 @@ class SelectionNode {...@@ -56,6 +56,13 @@ class SelectionNode {
56 get nodeType() {56 get nodeType() {
57 return this.tagName === "#text" ? 3 : 1;57 return this.tagName === "#text" ? 3 : 1;
58 }58 }
59 get ownerDocument() {
60 // `user-select: none` inherits, like Chromium's computed style.
61 const getComputedStyle = (element: SelectionNode) => ({
62 userSelect: element.closest("[data-select-none]") ? "none" : "auto",
63 });
64 return { defaultView: { getComputedStyle } };
65 }
59 get length() {66 get length() {
60 return this.data.length;67 return this.data.length;
61 }68 }
...@@ -259,6 +266,22 @@ describe("captureAssistantTextSelection", () => {...@@ -259,6 +266,22 @@ describe("captureAssistantTextSelection", () => {
259 expect(capture(source, nativeSelection([quote, 0], [timestampText, 1]))).toBeNull();266 expect(capture(source, nativeSelection([quote, 0], [timestampText, 1]))).toBeNull();
260 });267 });
261268
269 it("captures a triple-clicked response that ends before a tool row and the next response", () => {
270 const quote = textNode("Recording your answers in the session note:");
271 const source = assistantSource(new SelectionNode("P").append(quote));
272 const nextParagraph = new SelectionNode("P").append(textNode("1. What it means."));
273 const viewport = new SelectionNode("MAIN").append(
274 source,
275 new SelectionNode("DIV", "", { "data-select-none": "" }).append(textNode("echo ok")),
276 new SelectionNode("H3", "", { "data-select-none": "" }).append(textNode("T3 Code")),
277 assistantSource(nextParagraph),
278 );
279
280 expect(capture(viewport, nativeSelection([quote, 0], [nextParagraph, 0]))?.selector).toEqual(
281 selector(quote.data),
282 );
283 });
284
262 it("does not attribute an empty starting endpoint to the previous response", () => {285 it("does not attribute an empty starting endpoint to the previous response", () => {
263 const previous = textNode("Previous response.");286 const previous = textNode("Previous response.");
264 const quote = textNode("Selected response.");287 const quote = textNode("Selected response.");
apps/web/src/lib/assistantTextSelection.ts+9-1
...@@ -189,13 +189,21 @@ function isUsableRange(root: HTMLElement, range: Range): boolean {...@@ -189,13 +189,21 @@ function isUsableRange(root: HTMLElement, range: Range): boolean {
189 return true;189 return true;
190}190}
191191
192function isUnselectable(text: Text): boolean {
193 const element = text.parentElement;
194 const view = element?.ownerDocument?.defaultView;
195 return !!element && !!view && view.getComputedStyle(element).userSelect === "none";
196}
197
192function selectedTextBoundary(range: Range, node: Node, last: boolean): Text | null {198function selectedTextBoundary(range: Range, node: Node, last: boolean): Text | null {
193 if (!range.intersectsNode(node)) return null;199 if (!range.intersectsNode(node)) return null;
194 if (node.nodeType === 3) {200 if (node.nodeType === 3) {
195 const text = node as Text;201 const text = node as Text;
196 const start = node === range.startContainer ? range.startOffset : 0;202 const start = node === range.startContainer ? range.startOffset : 0;
197 const end = node === range.endContainer ? range.endOffset : text.length;203 const end = node === range.endContainer ? range.endOffset : text.length;
198 return start < end ? text : null;204 // Triple-click ranges run through unselectable rows (tool calls, timestamps,
205 // author headings) into the next response; that text is not selected.
206 return start < end && !isUnselectable(text) ? text : null;
199 }207 }
200 for (208 for (
201 let child = last ? node.lastChild : node.firstChild;209 let child = last ? node.lastChild : node.firstChild;