From d1816af5c709b1a5881efba7d08d750b9c51ad53 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 11 Sep 2026 15:36:24 +0800 Subject: [PATCH] fix(flow-chat): Respect BTW scroll intent - Keep upward scrolling detached from tail follow during streaming and idle states. - Resume following only after downward user scrolling reaches the bottom or the user clicks the scroll-to-bottom button. - Track wheel, keyboard, touch, and scrollbar intent and clear it when scrolling ends. - Hide user-message rollback actions in BTW session panels. - Add regression coverage and update focused verification guidance. --- .../src/flow_chat/components/btw/AGENTS.md | 2 +- .../components/btw/BtwSessionPanel.tsx | 32 ++--- .../components/btw/btwTailFollow.test.ts | 110 ++++++++++++++++++ .../flow_chat/components/btw/btwTailFollow.ts | 102 ++++++++++++++++ 4 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/btw/btwTailFollow.test.ts create mode 100644 src/web-ui/src/flow_chat/components/btw/btwTailFollow.ts diff --git a/src/web-ui/src/flow_chat/components/btw/AGENTS.md b/src/web-ui/src/flow_chat/components/btw/AGENTS.md index c8a124ede6..4ffd4091c0 100644 --- a/src/web-ui/src/flow_chat/components/btw/AGENTS.md +++ b/src/web-ui/src/flow_chat/components/btw/AGENTS.md @@ -18,7 +18,7 @@ UI state on each tab switch. Closing a tab releases its wrapper and view state. For transcript windowing, scrolling, or review integration changes, run: ```bash -pnpm --dir src/web-ui run test:run src/flow_chat/components/btw/useBtwSessionState.test.tsx src/flow_chat/components/btw/useBtwPanelViewport.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts +pnpm --dir src/web-ui run test:run src/flow_chat/components/btw/btwTailFollow.test.ts src/flow_chat/components/btw/useBtwSessionState.test.tsx src/flow_chat/components/btw/useBtwPanelViewport.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts ``` Native WebView2 checks remain manual: long subagent transcripts, streaming tail diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index ffc43a2a3d..f66ed7e571 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -70,6 +70,7 @@ import { import { FlowChatManager } from '../../services/FlowChatManager'; import { useSessionCompletionReceipt } from '../../hooks/useSessionCompletionReceipt'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; +import { bindBtwTailFollow } from './btwTailFollow'; function findReviewChildByRequestId( parentSessionId: string | null | undefined, @@ -290,35 +291,15 @@ const BtwSessionPanelContent: React.FC 120); - if (distanceFromBottom < 80 && !viewState.restoring) { - shouldAutoScrollRef.current = true; - } - viewState.followTail = shouldAutoScrollRef.current; - }, [viewState]); + }, []); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; - const handleWheel = (e: WheelEvent) => { - if (e.deltaY < 0) { - shouldAutoScrollRef.current = false; - viewState.followTail = false; - } else if (e.deltaY > 0) { - const { scrollTop, scrollHeight, clientHeight } = container; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - if (distanceFromBottom < 100) { - shouldAutoScrollRef.current = true; - } - } - }; - - container.addEventListener('wheel', handleWheel, { passive: true }); - container.addEventListener('scroll', updateScrollAffordance, { passive: true }); - updateScrollAffordance(); - return () => { - container.removeEventListener('wheel', handleWheel); - container.removeEventListener('scroll', updateScrollAffordance); - }; + return bindBtwTailFollow(container, (following) => { + shouldAutoScrollRef.current = following; + viewState.followTail = following; + }, updateScrollAffordance); }, [updateScrollAffordance, viewState]); useEffect(() => { @@ -386,6 +367,7 @@ const BtwSessionPanelContent: React.FC { + let scroller: HTMLDivElement; + let following: boolean; + let dispose: () => void; + const wheel = (deltaY: number) => scroller.dispatchEvent(new WheelEvent('wheel', { deltaY })); + const scroll = (top: number) => { + scroller.scrollTop = top; + scroller.dispatchEvent(new Event('scroll')); + }; + beforeEach(() => { + vi.useFakeTimers(); + scroller = document.createElement('div'); + document.body.append(scroller); + Object.defineProperties(scroller, { + clientHeight: { value: 500 }, + scrollHeight: { value: 1500, configurable: true }, + clientWidth: { value: 500 }, + }); + scroller.scrollTop = 1000; + following = true; + dispose = bindBtwTailFollow(scroller, value => { following = value; }, vi.fn()); + }); + afterEach(() => { dispose(); scroller.remove(); vi.useRealTimers(); }); + + it('keeps small upward gestures detached even inside the old 80px threshold', () => { + wheel(-20); + scroll(980); + expect(following).toBe(false); + // A measurement correction back to the tail is not downward user intent. + scroll(1000); + expect(following).toBe(false); + }); + + it('requires downward movement to the actual tail, not proximity', () => { + wheel(-60); scroll(940); + wheel(20); scroll(960); + expect(following).toBe(false); + wheel(40); + expect(following).toBe(false); + scroll(999); + expect(following).toBe(true); + }); + + it('does not resume when streamed content moves the tail away', () => { + wheel(-60); scroll(940); wheel(60); + Object.defineProperty(scroller, 'scrollHeight', { value: 1600 }); + scroll(1000); + expect(following).toBe(false); + }); + + it.each(['scrollend', 'timeout'])('expires downward intent on %s', end => { + wheel(-60); scroll(940); wheel(20); scroll(960); + if (end === 'scrollend') scroller.dispatchEvent(new Event('scrollend')); + else vi.advanceTimersByTime(181); + scroll(1000); + expect(following).toBe(false); + }); + + it('replaces pending downward intent when the user reverses direction', () => { + wheel(-60); scroll(940); wheel(20); scroll(960); wheel(-10); + scroll(1000); + expect(following).toBe(false); + }); + + it('supports keyboard scrolling but leaves nested controls alone', () => { + scroller.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + scroll(980); + expect(following).toBe(false); + const input = document.createElement('input'); + scroller.append(input); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + scroll(1000); + expect(following).toBe(false); + scroll(980); + scroller.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); + scroll(1000); + expect(following).toBe(true); + }); + + it('supports touch direction followed by actual scrolling', () => { + const touch = (type: string, clientY: number) => scroller.dispatchEvent( + new TouchEvent(type, { touches: [{ clientY } as Touch] }), + ); + touch('touchstart', 100); touch('touchmove', 120); scroll(980); + expect(following).toBe(false); + touch('touchmove', 100); scroll(1000); + expect(following).toBe(true); + }); + + it('detaches a scrollbar drag and resumes only when dragged down to the tail', () => { + scroller.dispatchEvent(new MouseEvent('pointerdown', { clientX: 505, button: 0 })); + scroll(980); + expect(following).toBe(false); + scroll(1000); + expect(following).toBe(true); + document.dispatchEvent(new Event('pointerup')); + }); + + it('removes listeners and the expiry timer on disposal', () => { + wheel(-20); scroll(980); wheel(20); + dispose(); + expect(vi.getTimerCount()).toBe(0); + scroll(1000); + expect(following).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/btw/btwTailFollow.ts b/src/web-ui/src/flow_chat/components/btw/btwTailFollow.ts new file mode 100644 index 0000000000..bc22c71ae1 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/btwTailFollow.ts @@ -0,0 +1,102 @@ +/** Bind user intent separately from scroll geometry and programmatic writes. */ +export function bindBtwTailFollow( + scroller: HTMLElement, + setFollowing: (following: boolean) => void, + updateAffordance: () => void, +) { + let direction = 0; + let previousTop = scroller.scrollTop; + let timer: ReturnType | undefined; + let draggingScrollbar = false; + let touchY: number | undefined; + const clearIntent = () => { + direction = 0; + clearTimeout(timer); + }; + // Fallback for hosts without scrollend; renew while the gesture is moving. + const expireIntent = () => { + clearTimeout(timer); + timer = setTimeout(clearIntent, 180); + }; + const intend = (next: number) => { + direction = next; + previousTop = scroller.scrollTop; + if (next < 0) setFollowing(false); + expireIntent(); + }; + const wheel = (event: WheelEvent) => { + if (!event.ctrlKey && event.deltaY !== 0) intend(Math.sign(event.deltaY)); + }; + const scroll = () => { + const top = scroller.scrollTop; + const movement = top - previousTop; + if (draggingScrollbar && movement !== 0) { + direction = Math.sign(movement); + if (direction < 0) setFollowing(false); + } + if (direction > 0 && movement > 0 && + scroller.scrollHeight - top - scroller.clientHeight <= 2) { + setFollowing(true); + clearIntent(); + } else if (direction !== 0) { + expireIntent(); + } + previousTop = top; + updateAffordance(); + }; + const keydown = (event: KeyboardEvent) => { + // Nested controls own their keyboard behavior. + if (event.target !== scroller || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return; + if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) intend(-1); + else if (['ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) intend(1); + }; + const touchstart = (event: TouchEvent) => { + clearIntent(); + touchY = event.touches[0]?.clientY; + }; + const touchmove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY; + if (nextY !== undefined && touchY !== undefined && nextY !== touchY) intend(Math.sign(touchY - nextY)); + touchY = nextY; + }; + const touchend = () => { touchY = undefined; expireIntent(); }; + const pointerdown = (event: PointerEvent) => { + if (event.target !== scroller || event.button !== 0) return; + const bounds = scroller.getBoundingClientRect(); + const contentLeft = bounds.left + scroller.clientLeft; + // Only the native vertical scrollbar gutter, not clicks in the transcript. + draggingScrollbar = event.clientX < contentLeft || event.clientX >= contentLeft + scroller.clientWidth; + if (draggingScrollbar) { + clearIntent(); + previousTop = scroller.scrollTop; + setFollowing(false); + } + }; + const pointerup = () => { draggingScrollbar = false; clearIntent(); }; + scroller.addEventListener('wheel', wheel, { passive: true }); + scroller.addEventListener('scroll', scroll, { passive: true }); + scroller.addEventListener('scrollend', clearIntent); + scroller.addEventListener('keydown', keydown); + scroller.addEventListener('touchstart', touchstart, { passive: true }); + scroller.addEventListener('touchmove', touchmove, { passive: true }); + scroller.addEventListener('touchend', touchend); + scroller.addEventListener('touchcancel', touchend); + scroller.addEventListener('pointerdown', pointerdown); + scroller.ownerDocument.addEventListener('pointerup', pointerup); + scroller.ownerDocument.addEventListener('pointercancel', pointerup); + updateAffordance(); + return () => { + clearIntent(); + scroller.removeEventListener('wheel', wheel); + scroller.removeEventListener('scroll', scroll); + scroller.removeEventListener('scrollend', clearIntent); + scroller.removeEventListener('keydown', keydown); + scroller.removeEventListener('touchstart', touchstart); + scroller.removeEventListener('touchmove', touchmove); + scroller.removeEventListener('touchend', touchend); + scroller.removeEventListener('touchcancel', touchend); + scroller.removeEventListener('pointerdown', pointerdown); + scroller.ownerDocument.removeEventListener('pointerup', pointerup); + scroller.ownerDocument.removeEventListener('pointercancel', pointerup); + }; +}