From 4bf5c02f861c6ade2668b99521d7c3e4fe942569 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:54:21 +0800 Subject: [PATCH] fix(agents): keep text selection within the conversation --- src/features/agents/SessionEditor.tsx | 2 + src/ui/index.ts | 1 + src/ui/useSelectionBoundary.test.tsx | 82 +++++++++++++++++++++++++++ src/ui/useSelectionBoundary.ts | 33 +++++++++++ 4 files changed, 118 insertions(+) create mode 100644 src/ui/useSelectionBoundary.test.tsx create mode 100644 src/ui/useSelectionBoundary.ts diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 18ea0ab91..d335026bb 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -20,6 +20,7 @@ import { copyText, fileIconName, selectableText, + useSelectionBoundary, showContextMenu, } from "@/ui"; import { useTx, tx } from "@/platform/i18n"; @@ -325,6 +326,7 @@ interface StreamProps { function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) { const ref = useRef(null); + useSelectionBoundary(ref); const stick = useRef(true); useLayoutEffect(() => { const el = ref.current; diff --git a/src/ui/index.ts b/src/ui/index.ts index 51362d811..223668cee 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -31,3 +31,4 @@ export * from "./BarButton"; export { lazyComponent, prefetchLazyComponents } from "./lazy"; export { T } from "./T"; export { selectableText, copyMenuFor, copyText } from "./selectableText"; +export { useSelectionBoundary } from "./useSelectionBoundary"; diff --git a/src/ui/useSelectionBoundary.test.tsx b/src/ui/useSelectionBoundary.test.tsx new file mode 100644 index 000000000..b11b17266 --- /dev/null +++ b/src/ui/useSelectionBoundary.test.tsx @@ -0,0 +1,82 @@ +import { useRef } from "react"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { useSelectionBoundary } from "./useSelectionBoundary"; + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + cleanup(); +}); + +function Pane() { + const ref = useRef(null); + useSelectionBoundary(ref); + return ( +
+

First message

+

Last message

+
+ ); +} + +function fixture() { + const view = render( + <> +
Header
+ +
Reply to the agent
+
Branch or tag
+ , + ); + const text = (value: string) => view.getByText(value).firstChild!; + const select = (anchor: Node, start: number, focus: Node, end: number) => { + const selection = window.getSelection()!; + selection.setBaseAndExtent(anchor, start, focus, end); + document.dispatchEvent(new Event("selectionchange")); + return selection; + }; + return { ...view, text, select }; +} + +it("clamps a downward selection before the composer and Git panel", () => { + const { text, select } = fixture(); + const selection = select(text("Last message"), 5, text("Branch or tag"), 13); + expect(selection.toString()).toBe("message"); + expect(selection.anchorNode).toBe(text("Last message")); + expect(selection.anchorOffset).toBe(5); +}); + +it("clamps an upward selection while retaining its direction", () => { + const { text, select } = fixture(); + const selection = select(text("First message"), 5, text("Header"), 0); + expect(selection.toString()).toBe("First"); + expect(selection.anchorNode).toBe(text("First message")); + expect(selection.anchorOffset).toBe(5); +}); + +it("preserves cross-message selection and lets the endpoint return inside", () => { + const { text, select } = fixture(); + select(text("First message"), 6, text("Branch or tag"), 13); + const selection = select(text("First message"), 6, text("Last message"), 4); + expect(selection.toString()).toBe("messageLast"); + expect(selection.focusNode).toBe(text("Last message")); +}); + +it("leaves selections originating outside the conversation alone", () => { + const { text, select } = fixture(); + const selection = select(text("Reply to the agent"), 0, text("Branch or tag"), 13); + expect(selection.toString()).toBe("Reply to the agentBranch or tag"); +}); + +it("removes the boundary listener when the pane unmounts", () => { + const { text, select, unmount } = fixture(); + const first = text("First message"); + const last = text("Branch or tag"); + const container = first.parentElement!.parentElement!.parentElement!; + const children = [...container.childNodes]; + unmount(); + container.append(...children); + document.body.append(container); + expect(select(first, 0, last, 13).toString()).toContain("Branch or tag"); + container.remove(); +}); diff --git a/src/ui/useSelectionBoundary.ts b/src/ui/useSelectionBoundary.ts new file mode 100644 index 000000000..ff464b362 --- /dev/null +++ b/src/ui/useSelectionBoundary.ts @@ -0,0 +1,33 @@ +import { useEffect, type RefObject } from "react"; + +/** Keep selections started in a reading pane from extending into neighboring panes. */ +export function useSelectionBoundary(ref: RefObject): void { + useEffect(() => { + const root = ref.current; + if (!root) return; + const doc = root.ownerDocument; + const clamp = () => { + const selection = doc.getSelection(); + if ( + !selection?.anchorNode || + !selection.focusNode || + selection.isCollapsed || + !root.contains(selection.anchorNode) || + root.contains(selection.focusNode) + ) + return; + + const bounds = doc.createRange(); + bounds.selectNodeContents(root); + const before = bounds.comparePoint(selection.focusNode, selection.focusOffset) < 0; + selection.setBaseAndExtent( + selection.anchorNode, + selection.anchorOffset, + root, + before ? 0 : root.childNodes.length, + ); + }; + doc.addEventListener("selectionchange", clamp); + return () => doc.removeEventListener("selectionchange", clamp); + }, [ref]); +}