From 07415048dff14b79b5a1a86fd24fa57ef2bc8ed1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:28:07 +0300 Subject: [PATCH] =?UTF-8?q?Scroll=20round=201=20(owner):=20overlay=20dock?= =?UTF-8?q?=20+=20spacer=20=E2=80=94=20the=20fold=20fires=20as=20soon=20as?= =?UTF-8?q?=20you=20leave=20the=20bottom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question box folded far too late because the old dock was IN FLOW inside the scroller: folding it shrank the scroll content, so the fold had to wait until the learner was a full dock-height past the bottom — any closer and the browser's scrollTop clamp pinned them back to the bottom, re-expanding the box (the oscillation the old hysteresis comments worked around). The dock now overlays the column bottom (absolute — a sibling of the scroller, the use-stick-to-bottom pattern, like the scroll button), and a spacer at the transcript's end reserves its height (DockOverlay measures its settled block and reports upward; ChatView's dockReserve state feeds the spacer). At-rest geometry is identical: the spacer reserves exactly the overlay footprint. The fold is now VISUAL ONLY: COLLAPSE_DIST 64px (was dock-height+64), EXPAND_DIST 16px, position-based, no velocity. The reserved tail GROWS immediately (typing, new variants) but never drops while a question is open — a shrinking tail would reintroduce the clamp; in chat mode drops apply (the composer typing-shrink is the old harmless behavior). expandIntent (2s bar-click suppression) and the teach reveal gate are unchanged. Verified live (headless chromium, dev 3001, pending-quiz chat): teach invitation mounts collapsed; click → expand + bottom; scroll up 200px → folds to the bar at d=200 with the spacer held (no geometry change, no yank); back to bottom → re-expands; spacer ≡ overlay footprint. Note: the overlay spans the column's full width, so on platforms with classic (non-overlay) scrollbars its gradient/background strip covers the bottom of the scrollbar track — invisible on macOS overlay scrollbars; flagged for master. --- components/chat.tsx | 186 ++++++++++++++++++++++++++++---------------- 1 file changed, 121 insertions(+), 65 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 71495e6..93549f9 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -40,6 +40,13 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; */ const COLUMN = "mx-auto w-full max-w-none px-6"; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; +// The dock overlay's gradient band (h-24) — part of the reserved tail. +const GRADIENT_H = 96; +// Fold thresholds (owner, scroll round 1): pixel distance from the bottom — +// position, not velocity. Leave the bottom → the box folds (visual only); +// reach it again → it re-opens. The dead band absorbs trackpad drift. +const COLLAPSE_DIST = 64; +const EXPAND_DIST = 16; /** * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat @@ -264,6 +271,13 @@ export function Chat({ // The per-chat model control state (model-picker writes via PUT; the // record is the source of truth, lifted here so the stats bar re-renders). const [settings, setSettings] = useState(initialSettings); + // The dock's reserved tail height (DockOverlay reports it; the spacer at + // the end of the transcript renders it). Lives here because the spacer is + // inside the scroller while the dock overlays OUTSIDE it — two parents. + // 208 ≈ gradient(96) + composer block(~112): the SSR/pre-hydration + // guess so the static paint has a sane tail. The first layout effect + // replaces it with the measured truth before paint. + const [dockReserve, setDockReserve] = useState(208); const syncTurns = useCallback(async () => { try { const res = await fetch(`/api/chats/${chatId}`); @@ -458,7 +472,8 @@ export function Chat({ the plan-tree panel as full-height SIBLINGS — the panel spans top to bottom exactly like the left sidebar, and the header (name + phase chip) spans from the left sidebar's edge to the panel's edge. The - dock stays sticky inside the scroller, untouched. */} + dock overlays the column bottom (outside the scroller); a spacer in + the transcript reserves its height. */}
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only @@ -600,45 +615,56 @@ export function Chat({ )}
- stop()} - canSend={canSend} - verdict={ - planVerdict - ? { - onAccept: () => sendVerdict(true), - onDecline: () => sendVerdict(false), - } - : undefined - } - /> + {/* The dock's reserved tail (DockOverlay reports its height): + keeps at-rest transcript content scrolling fully clear of + the overlay — the flow-space replacement for the old + sticky dock. Zero only pre-measurement (the first layout + effect reports before paint). */} +
+ + {/* The dock: an overlay sibling of the scroller (the + use-stick-to-bottom pattern — like the scroll button). Before + the button in DOM order so the button keeps painting above + the dock, as it always has. */} + stop()} + canSend={canSend} + verdict={ + planVerdict + ? { + onAccept: () => sendVerdict(true), + onDecline: () => sendVerdict(false), + } + : undefined } - onSubmitAnswer={(output) => { - if (!pending) return; - void addToolOutput({ - tool: pending.tool as "quiz" | "ask_user_question", - toolCallId: pending.toolCallId, - output, - }); - }} /> - - + } + onSubmitAnswer={(output) => { + if (!pending) return; + void addToolOutput({ + tool: pending.tool as "quiz" | "ask_user_question", + toolCallId: pending.toolCallId, + output, + }); + }} + /> +
@@ -652,14 +678,27 @@ export function Chat({ } /** - * The floating blob: ONE persistent shell, sticky at the bottom of the - * scroller (in flow — the transcript scrolls fully clear of it, and it never - * covers the scrollbar). Contents swap between the chat composer and the - * question dock; shell height morphs between variants (WAAPI, quick). + * The floating blob: ONE persistent shell, OVERLAID at the bottom of the + * column (absolute — a sibling of the scroller, the use-stick-to-bottom + * pattern; it replaced the old in-flow sticky dock, which died of the + * fold-latency problem below). Contents swap between the chat composer and + * the question dock; shell height morphs between variants (WAAPI, quick). A + * spacer at the end of the transcript (rendered by ChatView from onReserve + * reports) holds the dock's flow-space, so at-rest content scrolls fully + * clear of the overlay. * - * Collapse: scroll up and the dock folds into a slim bar; click it (or scroll - * back to the bottom) to re-expand. The question form stays MOUNTED while - * collapsed (hidden, not unmounted) so a half-made selection survives. + * Why an overlay (owner, scroll round 1): an in-flow dock IS scroll geometry + * — folding it shrinks the scroll content, so the fold had to wait until the + * learner was a full dock-height past the bottom (any closer and the browser + * clamp pinned them back, re-expanding the box: the oscillation). Decoupled + * from the flow, the fold is VISUAL ONLY: it fires as soon as the learner + * leaves the bottom, and the reserved tail only ever GROWS while a question + * is open (a shrinking tail would reintroduce the clamp). + * + * Collapse: scroll up past COLLAPSE_DIST and the dock folds into a slim bar; + * click it (or scroll back to the bottom) to re-expand. The question form + * stays MOUNTED while collapsed (hidden, not unmounted) so a half-made + * selection survives. */ function DockOverlay({ pending, @@ -671,6 +710,7 @@ function DockOverlay({ chatId, settings, onSettingsSaved, + onReserve, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ @@ -685,6 +725,9 @@ function DockOverlay({ chatId: string; settings?: ChatSettings; onSettingsSaved: (settings: ChatSettings) => void; + /** Reports the dock's settled height (block + gradient) — ChatView's + * dockReserve state feeds the transcript's spacer from it. */ + onReserve: (h: number) => void; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); // Teach-phase mount (session reload with a pending check): the dock starts @@ -718,13 +761,10 @@ function DockOverlay({ // 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. + // ── Fold machine (position, not velocity — owner, scroll round 1) ── + // The dock is an overlay: folding is visual-only and scroll geometry never + // moves, so the thresholds can hug the bottom. Leave it → fold; reach it + // again → re-open. The dead band keeps trackpad drift from chattering. const expandedRef = useRef(expanded); expandedRef.current = expanded; const pendingRef = useRef(!!pending); @@ -733,8 +773,21 @@ function DockOverlay({ requiresRevealRef.current = requiresReveal; const revealedRef = useRef(revealed); revealedRef.current = revealed; - const expandedHRef = useRef(0); - const collapsedHRef = useRef(42); + + // ── The reserve: the spacer's height, reported upward ── + // Grows immediately (typing, new variants). NEVER drops while a question + // is open — a shrinking reserved tail is scroll geometry and the browser + // clamp would pin a nearby learner to the bottom (the old oscillation). + // In chat mode drops apply (the composer's typing shrink is today's + // harmless in-flow behavior; nothing auto-expands there). + const reserveRef = useRef(0); + const blockRef = useRef(null); + const offer = (blockH: number) => { + const next = blockH + GRADIENT_H; + if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity + reserveRef.current = next; + onReserve(next); + }; // Explicit expand (bar click): the smooth scroll to the bottom takes // hundreds of ms, during which the position is still legitimately inside @@ -763,10 +816,9 @@ function DockOverlay({ 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 && !expandIntentRef.current) setExpanded(false); - } else if (d < 16) { + if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); + } else if (d < EXPAND_DIST) { // Teach checks stay collapsed until the learner opens them (W5): the // at-bottom auto-expand would pop the form over the teaching. if (requiresRevealRef.current && !revealedRef.current) return; @@ -778,10 +830,12 @@ function DockOverlay({ 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; + // The settled block height (gradient excluded), offered to the spacer. + // Only ever called with NATURAL heights — never the animated mid-morph + // value (the iso effect's getAnimations guard wraps every call). + const measureBlock = () => { + const b = blockRef.current; + if (b) offer(b.getBoundingClientRect().height); }; const mode = pending ? `question:${pending.toolCallId}` : "chat"; @@ -806,13 +860,15 @@ function DockOverlay({ if (el.getAnimations().length === 0) { const h = el.getBoundingClientRect().height; p.height = h; - recordHeight(h); + measureBlock(); // typing growth → the spacer grows with the box } return; } el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring const h = el.getBoundingClientRect().height; - recordHeight(h); + // Variant change → re-reserve. Growth applies instantly; a drop (the + // fold, or question → composer) is filtered by the offer policy. + measureBlock(); if (p && Math.abs(p.height - h) > 1) { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { duration: 180, @@ -823,9 +879,9 @@ function DockOverlay({ }); return ( -
+
-
+