authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 17:04:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 17:04:39-07:00
log522ebe65a542ace6f22d9ad95f95ef9ade7e87f1
tree56d8f25ec0589555618bd55672c0a9de0d277f94
parent6382268323f86e01b2d40206bcf33beccb3fb64a
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the composer open while selecting timeline text (#9499)

Co-authored-by: Claude Code <noreply@anthropic.com>

3 files changed, 105 insertions(+), 1 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+35-1
...@@ -206,6 +206,7 @@ import {...@@ -206,6 +206,7 @@ import {
206 resetComposerScrollGesture,206 resetComposerScrollGesture,
207 suppressActiveComposerScrollGesture,207 suppressActiveComposerScrollGesture,
208} from "./composerScrollGesture";208} from "./composerScrollGesture";
209import { selectionHoldsComposerOpen } from "./composerSelectionHold";
209import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";210import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
210211
211function ComposerVideoThumbnail({ file }: { file: File }) {212function ComposerVideoThumbnail({ file }: { file: File }) {
...@@ -4293,9 +4294,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4293,9 +4294,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4293 ) {4294 ) {
4294 return;4295 return;
4295 }4296 }
4297 if (
4298 !isMobileViewport &&
4299 selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode())
4300 ) {
4301 // The check runs again once the selection clears.
4302 return;
4303 }
4296 setIsComposerFocused(false);4304 setIsComposerFocused(false);
4297 });4305 });
4298 }, [isMobileViewport]);4306 }, [getTimelineScrollableNode, isMobileViewport]);
4307
4308 // A held collapse settles when the selection goes away, whether the user
4309 // clicked elsewhere, pressed Escape, or used the selection toolbar.
4310 useEffect(() => {
4311 if (isMobileViewport || !isComposerFocused) return;
4312 let wasHolding = false;
4313 const handleSelectionChange = () => {
4314 const holding = selectionHoldsComposerOpen(
4315 window.getSelection(),
4316 getTimelineScrollableNode(),
4317 );
4318 if (wasHolding && !holding) {
4319 scheduleComposerCollapseCheck();
4320 }
4321 wasHolding = holding;
4322 };
4323 document.addEventListener("selectionchange", handleSelectionChange);
4324 return () => {
4325 document.removeEventListener("selectionchange", handleSelectionChange);
4326 };
4327 }, [
4328 getTimelineScrollableNode,
4329 isComposerFocused,
4330 isMobileViewport,
4331 scheduleComposerCollapseCheck,
4332 ]);
42994333
4300 useEffect(() => {4334 useEffect(() => {
4301 if (isMobileViewport || !isComposerFocused) return;4335 if (isMobileViewport || !isComposerFocused) return;
apps/web/src/components/chat/composerSelectionHold.test.ts created+55
...@@ -0,0 +1,55 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { selectionHoldsComposerOpen } from "./composerSelectionHold";
4
5function node(parent: Node | null = null) {
6 const self = {
7 parent,
8 contains(other: Node | null): boolean {
9 let cursor: { parent: Node | null } | null = other as unknown as { parent: Node | null };
10 while (cursor) {
11 if (cursor === (self as unknown)) return true;
12 cursor = cursor.parent as { parent: Node | null } | null;
13 }
14 return false;
15 },
16 };
17 return self as unknown as Node;
18}
19
20function selection(anchor: Node, collapsed = false) {
21 return {
22 isCollapsed: collapsed,
23 rangeCount: 1,
24 getRangeAt: () => ({ commonAncestorContainer: anchor }) as Range,
25 };
26}
27
28describe("selectionHoldsComposerOpen", () => {
29 const timeline = node();
30 const message = node(timeline);
31 const elsewhere = node();
32
33 it("holds for a range inside the timeline", () => {
34 expect(selectionHoldsComposerOpen(selection(message), timeline)).toBe(true);
35 });
36
37 it("ignores a caret with nothing selected", () => {
38 expect(selectionHoldsComposerOpen(selection(message, true), timeline)).toBe(false);
39 });
40
41 it("ignores selections outside the timeline", () => {
42 expect(selectionHoldsComposerOpen(selection(elsewhere), timeline)).toBe(false);
43 });
44
45 it("ignores an empty or missing selection", () => {
46 expect(selectionHoldsComposerOpen(null, timeline)).toBe(false);
47 expect(
48 selectionHoldsComposerOpen(
49 { isCollapsed: false, rangeCount: 0, getRangeAt: () => ({}) as Range },
50 timeline,
51 ),
52 ).toBe(false);
53 expect(selectionHoldsComposerOpen(selection(message), null)).toBe(false);
54 });
55});
apps/web/src/components/chat/composerSelectionHold.ts created+15
...@@ -0,0 +1,15 @@
1/**
2 * Whether a live text selection inside the timeline should hold the composer
3 * open. A drag-select in the conversation blurs the composer, and letting it
4 * rest mid-gesture reflows the timeline, which dismisses the selection
5 * toolbar the user was about to use.
6 */
7export function selectionHoldsComposerOpen(
8 selection: Pick<Selection, "isCollapsed" | "rangeCount" | "getRangeAt"> | null,
9 timeline: Node | null,
10): boolean {
11 if (!timeline || !selection || selection.isCollapsed || selection.rangeCount === 0) {
12 return false;
13 }
14 return timeline.contains(selection.getRangeAt(0).commonAncestorContainer);
15}