fix: finish measured dock sizing and short-window scrolling

This commit is contained in:
amosblomqvist
2026-10-27 15:27:08 +02:00
parent 42814ba6a6
commit d30acd4fdd
3 changed files with 462 additions and 79 deletions
+46 -79
View File
@@ -26,6 +26,7 @@ import { Composer } from "@/components/composer";
import { useEnterSubmits } from "@/components/enter-submits";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { dockStateKey } from "@/components/dock-state-key";
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
import { useFieldCycle } from "@/components/field-cycle";
@@ -1972,6 +1973,17 @@ function DockOverlay({
const revealedRef = useRef(revealed);
revealedRef.current = revealed;
const blockRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const mode = pending
? `question:${pending.toolCallId}`
: practice
? `practice:${practice.toolCallId}`
: "chat";
// The plan marginalia's flank width (owner, plan-placement round): from
// the DOCK container's right edge to the block's right edge — the bare
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
@@ -2010,40 +2022,33 @@ function DockOverlay({
return () => ro.disconnect();
}, []);
// The reserve grows immediately, but never shrinks while a question or
// practice problem owns the dock: shrinking flow space could pin a nearby
// learner back to the bottom. Composer mode may give space back.
const reserveRef = useRef(0);
const offer = useCallback((blockH: number) => {
const next = blockH + GRADIENT_H;
if (next <= reserveRef.current + 0.5 && pendingRef.current) return;
// Stamp growth at the source, including child-only field resizes, so
// stick-to-bottom's catch-up is not mistaken for the learner leaving.
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
reserveRef.current = next;
onReserve(next);
}, [onReserve]);
// The overlay must not paint over the scroller's scrollbar (owner, scroll
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
// stable both-edges), so inset the overlay's right edge by the gutter —
// zero on macOS overlay scrollbars, the real width on classic platforms.
// The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round,
// 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems
// off absurdly short — the cap is now the AVAILABLE COLUMN (the
// scroller already starts below the top bar) minus a 2rem breathing
// gap, carried as --dock-max-h for the docks' scroll areas.
// Budget the whole solid dock, leaving only its question area flexible.
// The hook measures natural, visible content before the morph/reserve
// effect below. Short windows scroll the reading/question/answer body;
// the stats/model toolbar remains outside that overflow viewport.
const [sbInset, setSbInset] = useState(0);
const [dockMaxH, setDockMaxH] = useState<number | null>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const measure = () => {
setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
const block = blockRef.current;
const question = block?.querySelector<HTMLElement>("[data-dock-scroll-area]");
// The height budget belongs to the whole dock, including answer
// controls, stats, and padding. Only the question may consume the
// remaining space; otherwise the overlay grows behind the header.
const chrome = block && question ? block.offsetHeight - question.offsetHeight : 0;
setDockMaxH(Math.max(el.clientHeight - 32 - chrome, 0));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
if (blockRef.current) observer.observe(blockRef.current);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [scrollRef]);
useDockHeight({
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer,
});
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
// live turn the transcript content grows and stick-to-bottom chases it;
@@ -2079,48 +2084,11 @@ function DockOverlay({
const root = blockRef.current;
const el = scrollRef.current;
if (!root || !el) return;
const canScroll = (n: HTMLElement, dir: number) => {
if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable
return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5;
};
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native
}
e.preventDefault();
el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1);
};
const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el);
root.addEventListener("wheel", onWheel, { passive: false });
return () => root.removeEventListener("wheel", onWheel);
}, [scrollRef]);
// ── 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
// Reserve GROWTH is the phantom-scroll SOURCE (flicker round,
// 2026-09-26): the spacer's height lands in this commit, the smooth
// catch-up lags it by the growth amount, and the position machine
// used to read that lag as the learner leaving — form → bar → form
// (the dictation flush, reproduced d≈152 > COLLAPSE_DIST). Stamp the
// mute right where the spacer grows: it covers the scroll events of
// THIS growth no matter how long after the field stretched (the
// field grows on its own render; the offer lands on the NEXT
// DockOverlay render — ~1s apart live). Shrinks never stamp (the
// fold's own give-back must not self-mute the re-expand).
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
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
// the collapse zone — the machine would instantly re-collapse the dock
@@ -2222,11 +2190,6 @@ function DockOverlay({
if (b) offer(b.getBoundingClientRect().height);
};
const mode = pending
? `question:${pending.toolCallId}`
: practice
? `practice:${practice.toolCallId}`
: "chat";
// The opening variant of a session appears instantly — the fade/rise is
// reserved for mid-session transitions.
const initialModeRef = useRef(mode);
@@ -2234,11 +2197,9 @@ function DockOverlay({
// 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.
const shellRef = useRef<HTMLDivElement>(null);
// The fold's CONTENT fade (owner round, 2026-10-05): the shell height
// morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise
// on the toggle — no remount, so the question form's state survives.
const contentRef = useRef<HTMLDivElement>(null);
const prevExpandedForFadeRef = useRef(expanded);
useIsoLayoutEffect(() => {
if (prevExpandedForFadeRef.current === expanded) return;
@@ -2286,8 +2247,10 @@ function DockOverlay({
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div ref={blockRef} className="relative bg-background pb-5" style={{ "--dock-max-h": dockMaxH ? `${dockMaxH}px` : undefined } as React.CSSProperties}>
<div ref={blockRef} className="relative bg-background pb-5">
<div className={DOCK}>
<div ref={viewportRef} data-dock-viewport="" className="pointer-events-auto">
<div ref={bodyRef}>
{setupCta}
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
one-liners above the shell - right-aligned to where its corner
@@ -2316,11 +2279,13 @@ function DockOverlay({
/>
)}
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
{/* Include descendant margins (notably practice thinking's
bottom margin) in the natural content measurement. */}
<div
ref={contentRef}
key={mode}
className={mode === initialModeRef.current ? undefined : "dock-variant"}
>
ref={contentRef}
key={mode}
className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"}
>
{pending ? (
<QuestionDock
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
@@ -2355,6 +2320,8 @@ function DockOverlay({
)}
</div>
</div>
</div>
</div>
{/* The stats row: beneath the composer/question shell, not
flush to the screen bottom — static height, outside the
morphing shell so it never joins a height animation. */}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react";
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export interface DockHeightMeasurements {
columnHeight: number;
/** Stats/toolbar and block padding, outside the scrolling viewport. */
outsideHeight: number;
/** Everything in the viewport except the animated shell. */
beforeShellHeight: number;
shellBorderHeight: number;
/** Natural content height, not the shell's animated/clamped height. */
contentHeight: number;
/** Null when collapsed, in chat mode, or showing malformed input. */
questionHeight: number | null;
questionPaddingHeight: number;
}
export function dockHeightBudget(m: DockHeightMeasurements) {
const available = Math.max(m.columnHeight - 32, 0);
const viewportMaxHeight = Math.max(available - m.outsideHeight, 0);
const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0);
const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight;
const questionMaxHeight = m.questionHeight === null
? null
: Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0);
// A cap smaller than the question's padding leaves no content viewport.
// Deliberately uncap the question and scroll the whole content instead;
// do not confuse a measured zero with an unmeasured CSS fallback.
const scrollWholeContent = questionMaxHeight === null
? naturalHeight > viewportMaxHeight
: questionMaxHeight <= m.questionPaddingHeight;
return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent };
}
interface DockHeightElements {
column: HTMLElement;
block: HTMLElement;
viewport: HTMLElement;
body: HTMLElement;
shell: HTMLElement;
content: HTMLElement;
}
const pixels = (value: string) => Number.parseFloat(value) || 0;
/** Read natural geometry before writing any constraints. Cancel the shell's
* animated height out of both ancestor measurements, then substitute its
* natural content and borders. This stays valid during a WAAPI height morph
* and when the outer viewport is already capped. */
export function syncDockHeight(elements: DockHeightElements, expanded: boolean) {
const { column, block, viewport, body, shell, content } = elements;
// QuestionDock keeps a laid-out form in an invisible h-0 wrapper;
// PracticeDock removes its form. Neither hidden form is a height input.
const question = expanded ? content.querySelector<HTMLElement>("[data-dock-scroll-area]") : null;
const height = (el: HTMLElement) => el.getBoundingClientRect().height;
const shellStyle = getComputedStyle(shell);
const questionStyle = question ? getComputedStyle(question) : null;
const budget = dockHeightBudget({
columnHeight: column.clientHeight,
outsideHeight: Math.max(height(block) - height(viewport), 0),
beforeShellHeight: Math.max(height(body) - height(shell), 0),
shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth),
contentHeight: height(content),
questionHeight: question ? height(question) : null,
questionPaddingHeight: questionStyle
? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom)
: 0,
});
if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h");
else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`);
viewport.style.maxHeight = `${budget.viewportMaxHeight}px`;
// Overflow is visible in normal layouts: keep the shell's shadow intact.
// The stats/model toolbar is a sibling, never inside this overflow box.
viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible";
// Clamp animated starting heights too, so a previous tall variant cannot
// paint above the column while morphing into a constrained layout.
shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`;
return budget;
}
interface DockHeightRefs {
scrollRef: RefObject<HTMLElement | null>;
blockRef: RefObject<HTMLDivElement | null>;
viewportRef: RefObject<HTMLDivElement | null>;
bodyRef: RefObject<HTMLDivElement | null>;
shellRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
}
export function useDockHeight({
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled, mode, onScrollbarInset, onHeight,
}: DockHeightRefs & {
expanded: boolean;
enabled: boolean;
/** Reconnect observers when the keyed content node/scroll area changes. */
mode: string;
onScrollbarInset: (inset: number) => void;
/** Settled visible block height, fed through the existing reserve policy. */
onHeight: (height: number) => void;
}) {
const measure = useCallback(() => {
const column = scrollRef.current;
const block = blockRef.current;
const viewport = viewportRef.current;
const body = bodyRef.current;
const shell = shellRef.current;
const content = contentRef.current;
if (!column || !block || !viewport || !body || !shell || !content) return;
onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2));
if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded);
else {
block.style.removeProperty("--dock-max-h");
viewport.style.removeProperty("max-height");
viewport.style.removeProperty("overflow-y");
shell.style.removeProperty("max-height");
}
// ResizeObserver runs even when only a child field changed and React
// did not rerender DockOverlay. Keep its transcript reserve current,
// but never report a mid-morph height.
if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height);
}, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]);
// Imperative constraints land before the overlay's later layout effect
// measures its morph target/reserve, and before paint (including reveal).
useIsoLayoutEffect(measure);
useIsoLayoutEffect(() => {
const observer = new ResizeObserver(measure);
for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) {
if (el) observer.observe(el);
}
const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null;
if (question) observer.observe(question);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]);
}
/** Overlay and transcript are siblings. Keep native scrolling in an actual
* scroll container (including the short-window viewport), then forward only
* at its edge. overflow:hidden/visible boxes cannot consume the wheel. */
export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) {
if (event.deltaY === 0) return;
for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue;
if (n.scrollHeight - n.clientHeight < 2) continue;
if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return;
}
event.preventDefault();
transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1);
}