Compare commits

...
Author SHA1 Message Date
l0ng-ai 4bf5c02f86 fix(agents): keep text selection within the conversation 2026-10-30 11:54:21 +08:00
4 changed files with 118 additions and 0 deletions

No files matched your search

+2
View File
@@ -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<HTMLDivElement>(null);
useSelectionBoundary(ref);
const stick = useRef(true);
useLayoutEffect(() => {
const el = ref.current;
+1
View File
@@ -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";
+82
View File
@@ -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<HTMLDivElement>(null);
useSelectionBoundary(ref);
return (
<div ref={ref}>
<p>First message</p>
<p>Last message</p>
</div>
);
}
function fixture() {
const view = render(
<>
<div>Header</div>
<Pane />
<div>Reply to the agent</div>
<div>Branch or tag</div>
</>,
);
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();
});
+33
View File
@@ -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<HTMLElement | null>): 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]);
}