From 6283d21311c22c755a70b5d62d9535add34cf3f5 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Fri, 25 Sep 2026 13:32:29 +0800 Subject: [PATCH] fix(web): scroll assistant to newest turn and settle the rail on release Opening the panel left the user at the oldest message because the scroll effect did not depend on `open`. Add it and defer the scroll to the next frame so the popover has laid out before the sentinel is measured. A click or an aborted swipe never arms the long press, so no drop handler ran and a hover-expanded button stayed expanded after the pointer left. Track real pointer presence and re-sync the rail on release. --- .../home/components/WorkspaceAssistant.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/web/src/app/home/components/WorkspaceAssistant.tsx b/web/src/app/home/components/WorkspaceAssistant.tsx index 5a3d45452..2e3003b38 100644 --- a/web/src/app/home/components/WorkspaceAssistant.tsx +++ b/web/src/app/home/components/WorkspaceAssistant.tsx @@ -90,6 +90,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { // control restores the full button, and the two states never race because // every transition is derived from the same `dockedEdge` snapshot. const [railExpanded, setRailExpanded] = useState(false); + const pointerOver = useRef(false); const containerRef = useRef(null); const hoverLocked = useRef(false); const dragState = useRef<{ @@ -234,12 +235,14 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { * reliable) and clears the lock; only then does hovering reveal the button. */ function handlePointerEnter() { + pointerOver.current = true; if (hoverLocked.current) return; if (shouldExpandRail({ dockedEdge, railExpanded, dragging })) setRailExpanded(true); } function handlePointerLeave() { + pointerOver.current = false; hoverLocked.current = false; // Never collapse mid-drag; the drop handler owns the final state. if (dragState.current || dragging) return; @@ -247,6 +250,17 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { setRailExpanded(false); } + /* + * A click or an aborted swipe never arms the long press, so no drop handler + * runs. Re-sync the rail from the real pointer position on release, otherwise + * a button expanded by hover would stay expanded with the cursor gone. + */ + function settleRailAfterRelease() { + if (pointerOver.current || dragState.current || dragging) return; + if (shouldCollapseRail({ dockedEdge, railExpanded, dragging })) + setRailExpanded(false); + } + function lockHoverUntilPointerExit() { hoverLocked.current = true; const releaseOnExit = (moveEvent: PointerEvent) => { @@ -309,6 +323,9 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { window.addEventListener('pointerup', onWindowUp); window.addEventListener('pointercancel', onWindowUp); + // Release outside a drag still needs to reconcile the rail. + window.addEventListener('pointerup', settleRailAfterRelease, { once: true }); + longPressTimer.current = window.setTimeout(() => { if (dragState.current !== state) return; state.active = true; @@ -371,9 +388,16 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, storageKey]); + // `open` is a dependency so a freshly opened panel jumps to the newest turn + // instead of leaving the user at the oldest message. The rAF waits for the + // popover to lay out before measuring the sentinel. useEffect(() => { - end.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, [conversation, busy, pendingText]); + if (!open) return; + const frame = window.requestAnimationFrame(() => { + end.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); + }); + return () => window.cancelAnimationFrame(frame); + }, [open, conversation, busy, pendingText]); async function submit(approved?: boolean) { if (