Scroll round 1 (owner): overlay dock + spacer — the fold fires as soon as you leave the bottom

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.
This commit is contained in:
amosblomqvist
2026-09-14 08:28:07 +03:00
parent 6505f12d37
commit 07415048df
+121 -65
View File
@@ -40,6 +40,13 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
*/ */
const COLUMN = "mx-auto w-full max-w-none px-6"; const COLUMN = "mx-auto w-full max-w-none px-6";
const DOCK = "mx-auto w-full max-w-[46rem] px-4"; 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 * 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 // 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). // record is the source of truth, lifted here so the stats bar re-renders).
const [settings, setSettings] = useState<ChatSettings | undefined>(initialSettings); const [settings, setSettings] = useState<ChatSettings | undefined>(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 () => { const syncTurns = useCallback(async () => {
try { try {
const res = await fetch(`/api/chats/${chatId}`); 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 the plan-tree panel as full-height SIBLINGS — the panel spans top to
bottom exactly like the left sidebar, and the header (name + phase 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 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. */}
<div className="flex h-dvh"> <div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only {/* h-10, not py-2: the toggle (a size-9 icon button) renders only
@@ -600,45 +615,56 @@ export function Chat({
)} )}
</div> </div>
<DockOverlay {/* The dock's reserved tail (DockOverlay reports its height):
pending={pending} keeps at-rest transcript content scrolling fully clear of
chatId={chatId} the overlay — the flow-space replacement for the old
settings={settings} sticky dock. Zero only pre-measurement (the first layout
onSettingsSaved={setSettings} effect reports before paint). */}
requiresReveal={requiresReveal} <div aria-hidden data-testid="dock-spacer" style={{ height: dockReserve }} />
phase={phases.at(-1) ?? "probe"} </ConversationContent>
turns={turns} {/* The dock: an overlay sibling of the scroller (the
composer={ use-stick-to-bottom pattern — like the scroll button). Before
<Composer the button in DOM order so the button keeps painting above
// Stop applies only to a LOCAL stream: a server-side turn has no the dock, as it always has. */}
// client request to abort (stop() would be a no-op lie). <DockOverlay
busy={localStream} pending={pending}
value={text} chatId={chatId}
onChange={setText} settings={settings}
onSend={onSend} onSettingsSaved={setSettings}
onStop={() => stop()} requiresReveal={requiresReveal}
canSend={canSend} phase={phases.at(-1) ?? "probe"}
verdict={ turns={turns}
planVerdict onReserve={setDockReserve}
? { composer={
onAccept: () => sendVerdict(true), <Composer
onDecline: () => sendVerdict(false), // Stop applies only to a LOCAL stream: a server-side turn has no
} // client request to abort (stop() would be a no-op lie).
: undefined busy={localStream}
} value={text}
/> onChange={setText}
onSend={onSend}
onStop={() => 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,
});
}}
/> />
</ConversationContent> }
<ConversationScrollButton /> onSubmitAnswer={(output) => {
if (!pending) return;
void addToolOutput({
tool: pending.tool as "quiz" | "ask_user_question",
toolCallId: pending.toolCallId,
output,
});
}}
/>
<ConversationScrollButton />
</Conversation> </Conversation>
</div> </div>
</div> </div>
@@ -652,14 +678,27 @@ export function Chat({
} }
/** /**
* The floating blob: ONE persistent shell, sticky at the bottom of the * The floating blob: ONE persistent shell, OVERLAID at the bottom of the
* scroller (in flow — the transcript scrolls fully clear of it, and it never * column (absolute — a sibling of the scroller, the use-stick-to-bottom
* covers the scrollbar). Contents swap between the chat composer and the * pattern; it replaced the old in-flow sticky dock, which died of the
* question dock; shell height morphs between variants (WAAPI, quick). * 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 * Why an overlay (owner, scroll round 1): an in-flow dock IS scroll geometry
* back to the bottom) to re-expand. The question form stays MOUNTED while * — folding it shrinks the scroll content, so the fold had to wait until the
* collapsed (hidden, not unmounted) so a half-made selection survives. * 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({ function DockOverlay({
pending, pending,
@@ -671,6 +710,7 @@ function DockOverlay({
chatId, chatId,
settings, settings,
onSettingsSaved, onSettingsSaved,
onReserve,
}: { }: {
pending: ReturnType<typeof findPendingToolCall>; pending: ReturnType<typeof findPendingToolCall>;
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
@@ -685,6 +725,9 @@ function DockOverlay({
chatId: string; chatId: string;
settings?: ChatSettings; settings?: ChatSettings;
onSettingsSaved: (settings: ChatSettings) => void; 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(); const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Teach-phase mount (session reload with a pending check): the dock starts // 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 // fire only when a NEW question lands
}, [pendingId]); }, [pendingId]);
// ── Collapse state machine with hysteresis ── // ── Fold machine (position, not velocity — owner, scroll round 1) ──
// Collapsing frees ~Δpx of flow height. If it happened while the learner is // The dock is an overlay: folding is visual-only and scroll geometry never
// within Δ of the bottom, the browser clamps scrollTop to the new max — // moves, so the thresholds can hug the bottom. Leave it → fold; reach it
// landing them AT the bottom, which re-expands the dock, which re-collapses… // again → re-open. The dead band keeps trackpad drift from chattering.
// 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); const expandedRef = useRef(expanded);
expandedRef.current = expanded; expandedRef.current = expanded;
const pendingRef = useRef(!!pending); const pendingRef = useRef(!!pending);
@@ -733,8 +773,21 @@ function DockOverlay({
requiresRevealRef.current = requiresReveal; requiresRevealRef.current = requiresReveal;
const revealedRef = useRef(revealed); const revealedRef = useRef(revealed);
revealedRef.current = 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<HTMLDivElement>(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 // Explicit expand (bar click): the smooth scroll to the bottom takes
// hundreds of ms, during which the position is still legitimately inside // hundreds of ms, during which the position is still legitimately inside
@@ -763,10 +816,9 @@ function DockOverlay({
const onScroll = () => { const onScroll = () => {
if (!pendingRef.current) return; if (!pendingRef.current) return;
const d = el.scrollHeight - el.clientHeight - el.scrollTop; const d = el.scrollHeight - el.clientHeight - el.scrollTop;
const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240);
if (expandedRef.current) { if (expandedRef.current) {
if (d > delta + 64 && !expandIntentRef.current) setExpanded(false); if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false);
} else if (d < 16) { } else if (d < EXPAND_DIST) {
// Teach checks stay collapsed until the learner opens them (W5): the // Teach checks stay collapsed until the learner opens them (W5): the
// at-bottom auto-expand would pop the form over the teaching. // at-bottom auto-expand would pop the form over the teaching.
if (requiresRevealRef.current && !revealedRef.current) return; if (requiresRevealRef.current && !revealedRef.current) return;
@@ -778,10 +830,12 @@ function DockOverlay({
return () => el.removeEventListener("scroll", onScroll); return () => el.removeEventListener("scroll", onScroll);
}, [scrollRef, scrollToBottom]); }, [scrollRef, scrollToBottom]);
const recordHeight = (h: number) => { // The settled block height (gradient excluded), offered to the spacer.
if (!pendingRef.current) return; // Only ever called with NATURAL heights — never the animated mid-morph
if (expandedRef.current) expandedHRef.current = h; // value (the iso effect's getAnimations guard wraps every call).
else collapsedHRef.current = h; const measureBlock = () => {
const b = blockRef.current;
if (b) offer(b.getBoundingClientRect().height);
}; };
const mode = pending ? `question:${pending.toolCallId}` : "chat"; const mode = pending ? `question:${pending.toolCallId}` : "chat";
@@ -806,13 +860,15 @@ function DockOverlay({
if (el.getAnimations().length === 0) { if (el.getAnimations().length === 0) {
const h = el.getBoundingClientRect().height; const h = el.getBoundingClientRect().height;
p.height = h; p.height = h;
recordHeight(h); measureBlock(); // typing growth → the spacer grows with the box
} }
return; return;
} }
el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring
const h = el.getBoundingClientRect().height; 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) { if (p && Math.abs(p.height - h) > 1) {
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
duration: 180, duration: 180,
@@ -823,9 +879,9 @@ function DockOverlay({
}); });
return ( return (
<div className="pointer-events-none sticky bottom-0 mt-auto"> <div className="pointer-events-none absolute inset-x-0 bottom-0">
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" /> <div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4"> <div ref={blockRef} className="bg-background pb-4">
<div className={DOCK}> <div className={DOCK}>
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg"> <div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}> <div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>