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 {
5656 get nodeType() {
5757 return this.tagName === "#text" ? 3 : 1;
5858 }
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 }
5966 get length() {
6067 return this.data.length;
6168 }
......@@ -259,6 +266,22 @@ describe("captureAssistantTextSelection", () => {
259266 expect(capture(source, nativeSelection([quote, 0], [timestampText, 1]))).toBeNull();
260267 });
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
262285 it("does not attribute an empty starting endpoint to the previous response", () => {
263286 const previous = textNode("Previous response.");
264287 const quote = textNode("Selected response.");
apps/web/src/lib/assistantTextSelection.ts+9-1
......@@ -189,13 +189,21 @@ function isUsableRange(root: HTMLElement, range: Range): boolean {
189189 return true;
190190}
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
192198function selectedTextBoundary(range: Range, node: Node, last: boolean): Text | null {
193199 if (!range.intersectsNode(node)) return null;
194200 if (node.nodeType === 3) {
195201 const text = node as Text;
196202 const start = node === range.startContainer ? range.startOffset : 0;
197203 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;
199207 }
200208 for (
201209 let child = last ? node.lastChild : node.firstChild;