From 939e1e2e5e622693709d6b46e4d6ff7b55f5f85a Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 06:22:40 +0300 Subject: [PATCH] Scroll behavior: hysteresis collapse, expand-scrolls-to-bottom, streaming follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Oscillation fix: collapsing the dock frees ~Δpx of flow height; within Δ of the bottom the browser clamps scrollTop to the new max, landing the learner AT the bottom, which re-expanded the dock → loop. Now a hysteresis state machine on the scroller's scroll events: collapse only when distance-from-bottom > Δ+64 (strictly clamp-proof), expand only within 16px of the bottom. ~370px dead band; exactly one transition per direction verified live. Heights measured live per question. - Reaching the bottom re-expands the dock AND scrolls to reveal it fully (previously it grew over the content and left you short of the bottom). - Streaming follow fixed: ConversationContent had h-full, so the observed content box never grew and use-stick-to-bottom's ResizeObserver never fired — no auto-scroll during streaming. min-h-full restores growth. Verified: distance-from-bottom ≤70px transient during a 25s stream, settles at 1px. --- components/chat.tsx | 71 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 94ab2da..d9673f2 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -96,7 +96,7 @@ export function Chat({
- +
{messages.length === 0 && !busy && ( void; }) { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); - const [forceOpen, setForceOpen] = useState(false); - useEffect(() => { - if (!isAtBottom) setForceOpen(false); - }, [isAtBottom]); - const expanded = isAtBottom || forceOpen; + const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); + const [expanded, setExpanded] = useState(true); - const mode = pending ? `question:${pending.toolCallId}` : "chat"; - - // A new question while the learner is at the bottom: scroll so the dock - // never covers the transcript tail. (Scrolled up → collapsed bar, no yank.) const pendingId = pending?.toolCallId ?? null; + + // New question: open expanded if the learner is following at the bottom + // (scroll to reveal it); if they're reading history, arrive as the slim bar. useEffect(() => { - if (pendingId && isAtBottom) void scrollToBottom(); + if (pendingId) { + setExpanded(isAtBottom); + if (isAtBottom) void scrollToBottom(); + } // eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands }, [pendingId]); + // ── Collapse state machine with hysteresis ── + // Collapsing frees ~Δpx of flow height. If it happened while the learner is + // within Δ of the bottom, the browser clamps scrollTop to the new max — + // landing them AT the bottom, which re-expands the dock, which re-collapses… + // the oscillation. So: collapse only once scrolled a full dock-height past + // the bottom (clamp-proof), expand only within 16px of the bottom, and leave + // a wide dead band between the two thresholds. Heights measured live. + const expandedRef = useRef(expanded); + expandedRef.current = expanded; + const pendingRef = useRef(!!pending); + pendingRef.current = !!pending; + const expandedHRef = useRef(0); + const collapsedHRef = useRef(42); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onScroll = () => { + if (!pendingRef.current) return; + const d = el.scrollHeight - el.clientHeight - el.scrollTop; + const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240); + if (expandedRef.current) { + if (d > delta + 64) setExpanded(false); + } else if (d < 16) { + setExpanded(true); + void scrollToBottom(); + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [scrollRef, scrollToBottom]); + + const recordHeight = (h: number) => { + if (!pendingRef.current) return; + if (expandedRef.current) expandedHRef.current = h; + else collapsedHRef.current = h; + }; + + const mode = pending ? `question:${pending.toolCallId}` : "chat"; + // Height morph: animate the shell from its previous height to its new one // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. @@ -235,11 +273,16 @@ function DockOverlay({ // Same variant: keep the settled height fresh, but never measure while // a morph is in flight — getBoundingClientRect would read the ANIMATED // value and corrupt the start height of the next morph. - if (el.getAnimations().length === 0) p.height = el.getBoundingClientRect().height; + if (el.getAnimations().length === 0) { + const h = el.getBoundingClientRect().height; + p.height = h; + recordHeight(h); + } return; } el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring const h = el.getBoundingClientRect().height; + recordHeight(h); if (p && Math.abs(p.height - h) > 1) { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { duration: 180, @@ -263,7 +306,7 @@ function DockOverlay({ input={pending.input as never} expanded={expanded} onExpand={() => { - setForceOpen(true); + setExpanded(true); void scrollToBottom(); }} onSubmit={onSubmitAnswer}