diff --git a/components/chat.tsx b/components/chat.tsx index 5e2f079..1926cce 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1770,16 +1770,48 @@ function DockOverlay({ // 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. const [sbInset, setSbInset] = useState(0); + const [dockMaxH, setDockMaxH] = useState(null); useEffect(() => { const el = scrollRef.current; if (!el) return; - const measure = () => setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); + const measure = () => { + setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); + setDockMaxH(Math.max(el.clientHeight - 32, 160)); + }; measure(); window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [scrollRef]); + // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a + // live turn the transcript content grows and stick-to-bottom chases it; + // the transient distance-from-bottom used to read as the learner + // leaving, folding the dock open↔bar ("disappear and appear again"). + // A ResizeObserver on the scroller's content stamps the SAME mute the + // dock's own resizes use — growth frames can never fold. Shrinks stamp + // nothing (the fold's own morph shrinks the reserve; muting that would + // self-mute the at-bottom re-expand). + const growthAtRef = useRef(0); + useEffect(() => { + const el = scrollRef.current; + const content = el?.firstElementChild as HTMLElement | null | undefined; + if (!el || !content) return; + let prevH = content.getBoundingClientRect().height; + const ro = new ResizeObserver(() => { + const h = content.getBoundingClientRect().height; + if (h > prevH + 0.5) growthAtRef.current = Date.now(); + prevH = Math.max(prevH, h); + }); + ro.observe(content); + return () => ro.disconnect(); + }, [scrollRef]); + // Wheel over the dock must still scroll the transcript (owner, scroll // round 2): the overlay is a SIBLING of the scroller, so the browser's // native scroll chaining stops at the overlay (the old in-flow dock @@ -1871,9 +1903,16 @@ function DockOverlay({ // reading the transient position folded the box to the bar for a // flicker. Real scrolling resumes after the brief mute window. if (Date.now() - geometryAtRef.current < 400) return; + // Growth mute (owner round, 2026-10-05): the transcript growing + // under a live turn is not the learner leaving. + if (Date.now() - growthAtRef.current < 400) return; const d = el.scrollHeight - el.clientHeight - el.scrollTop; if (expandedRef.current) { - if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); + // INTENT-GATED FOLD (owner ruling, 2026-10-05): distance alone + // folded on stick-to-bottom's catch-up transients; a real fold + // needs the distance AND stick-to-bottom agreeing the learner + // left the bottom. + if (d > COLLAPSE_DIST && !expandIntentRef.current && !atBottomRef.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. @@ -1907,6 +1946,19 @@ function DockOverlay({ // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. const shellRef = useRef(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(null); + const prevExpandedForFadeRef = useRef(expanded); + useIsoLayoutEffect(() => { + if (prevExpandedForFadeRef.current === expanded) return; + prevExpandedForFadeRef.current = expanded; + contentRef.current?.animate( + [{ opacity: 0, transform: "translateY(6px)" }, { opacity: 1, transform: "translateY(0)" }], + { duration: 160, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" }, + ); + }, [expanded]); const stateKey = `${mode}:${pending ? expanded : "-"}`; const prevRef = useRef<{ key: string; height: number } | null>(null); useIsoLayoutEffect(() => { @@ -1941,7 +1993,7 @@ function DockOverlay({ return (
-
+
{setupCta} {/* The queued-steer indicator (owner spec 2026-10-03): quiet @@ -1962,7 +2014,11 @@ function DockOverlay({ /> )}
-
+
{pending ? ( - {/* The agent's half: the statement, markdown + LaTeX rendered. */} -
+ {/* The agent's half: the 'Problem' eyebrow (mirroring the quiz + cards' label row — owner round, 2026-10-05) + the statement, + markdown + LaTeX rendered, capped by the measured column + (--dock-max-h; the old fixed 40vh cut long problems short). */} +
+
Problem
{input.statement}
diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 98736e7..fb65528 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -510,8 +510,10 @@ function QuestionDockForm({ return (
- {/* Question area — scrolls internally if very tall; the bottom row stays put. */} -
+ {/* Question area — scrolls internally if very tall; the bottom row stays put. + Capped by the measured column (--dock-max-h; the old fixed 55dvh cut + long questions short — owner round, 2026-10-05). */} +
{kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"} {qd.mode === "multi-select" && " · select all that apply"} diff --git a/tests/render-practice-surface.test.tsx b/tests/render-practice-surface.test.tsx index 052c84a..f0ac490 100644 --- a/tests/render-practice-surface.test.tsx +++ b/tests/render-practice-surface.test.tsx @@ -50,6 +50,11 @@ describe("PracticeDock", () => { />, ); assert.match(html, /Show that the sum is even\./); + // The 'Problem' eyebrow (owner round, 2026-10-05): the quiz cards' + // label row, mirrored — dock and transcript card read identically. + assert.match(html, /Problem/); + // The measured column cap (the old fixed 40vh is only the fallback). + assert.match(html, /--dock-max-h/); assert.match(html, /Your thinking/); assert.match(html, /data-testid="practice-nudge"/); assert.match(html, /data-testid="practice-commit"/);