ui: intent-gated fold, measured height caps, the Problem eyebrow, fold fade
Owner round (2026-10-05), Slice D — the dock mechanics: - INTENT-GATED FOLD: a ResizeObserver on the scroller's content stamps the existing 400ms mute whenever the transcript GROWS — growth frames (a live turn streaming under stick-to-bottom's chase) can never fold the dock; and a fold now needs the distance AND stick-to-bottom's isAtBottom agreeing the learner left. Fixes the generation churn (dock disappearing and reappearing while the agent works). Shrinks stamp nothing (the fold's own morph must not self-mute re-expand). - MEASURED HEIGHT CAPS: the question (55dvh) and problem (40vh) areas now cap at the available column (scroller clientHeight − 2rem, re-measured on resize) via --dock-max-h — questions stretch to the top bar with sensible padding and scroll internally only when genuinely huge. - The practice dock's statement area carries the 'Problem' eyebrow — the quiz cards' label row, mirrored (dock and transcript card read identically). - The fold's CONTENT fade: a 160ms WAAPI fade/rise on the bar↔form toggle — no remount, so the question form's state survives; the shell height morph was already animated.
This commit is contained in:
+60
-4
@@ -1770,16 +1770,48 @@ function DockOverlay({
|
|||||||
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
||||||
// stable both-edges), so inset the overlay's right edge by the 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.
|
// 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 [sbInset, setSbInset] = useState(0);
|
||||||
|
const [dockMaxH, setDockMaxH] = useState<number | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
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();
|
measure();
|
||||||
window.addEventListener("resize", measure);
|
window.addEventListener("resize", measure);
|
||||||
return () => window.removeEventListener("resize", measure);
|
return () => window.removeEventListener("resize", measure);
|
||||||
}, [scrollRef]);
|
}, [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
|
// 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
|
// 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
|
// 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
|
// reading the transient position folded the box to the bar for a
|
||||||
// flicker. Real scrolling resumes after the brief mute window.
|
// flicker. Real scrolling resumes after the brief mute window.
|
||||||
if (Date.now() - geometryAtRef.current < 400) return;
|
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;
|
const d = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||||
if (expandedRef.current) {
|
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) {
|
} 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.
|
||||||
@@ -1907,6 +1946,19 @@ function DockOverlay({
|
|||||||
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
||||||
// growth keeps the same stateKey and is NOT animated.
|
// growth keeps the same stateKey and is NOT animated.
|
||||||
const shellRef = useRef<HTMLDivElement>(null);
|
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;
|
||||||
|
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 stateKey = `${mode}:${pending ? expanded : "-"}`;
|
||||||
const prevRef = useRef<{ key: string; height: number } | null>(null);
|
const prevRef = useRef<{ key: string; height: number } | null>(null);
|
||||||
useIsoLayoutEffect(() => {
|
useIsoLayoutEffect(() => {
|
||||||
@@ -1941,7 +1993,7 @@ function DockOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
|
<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 className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||||
<div ref={blockRef} className="relative bg-background pb-5">
|
<div ref={blockRef} className="relative bg-background pb-5" style={{ "--dock-max-h": dockMaxH ? `${dockMaxH}px` : undefined } as React.CSSProperties}>
|
||||||
<div className={DOCK}>
|
<div className={DOCK}>
|
||||||
{setupCta}
|
{setupCta}
|
||||||
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
||||||
@@ -1962,7 +2014,11 @@ function DockOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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
|
||||||
|
ref={contentRef}
|
||||||
|
key={mode}
|
||||||
|
className={mode === initialModeRef.current ? undefined : "dock-variant"}
|
||||||
|
>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<QuestionDock
|
<QuestionDock
|
||||||
toolName={pending.tool as "quiz" | "ask_user_question" | "explain"}
|
toolName={pending.tool as "quiz" | "ask_user_question" | "explain"}
|
||||||
|
|||||||
@@ -92,8 +92,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
/* ── the expanded form ── */
|
/* ── the expanded form ── */
|
||||||
return (
|
return (
|
||||||
<div data-testid="practice-dock" data-problem={problemCallId}>
|
<div data-testid="practice-dock" data-problem={problemCallId}>
|
||||||
{/* The agent's half: the statement, markdown + LaTeX rendered. */}
|
{/* The agent's half: the 'Problem' eyebrow (mirroring the quiz
|
||||||
<div className="max-h-[40vh] overflow-y-auto px-4 pt-3 pb-2">
|
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). */}
|
||||||
|
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
||||||
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{input.statement}</MessageResponse>
|
<MessageResponse>{input.statement}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -510,8 +510,10 @@ function QuestionDockForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} data-testid="question-dock">
|
<div ref={rootRef} data-testid="question-dock">
|
||||||
{/* Question area — scrolls internally if very tall; the bottom row stays put. */}
|
{/* Question area — scrolls internally if very tall; the bottom row stays put.
|
||||||
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3">
|
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
|
||||||
|
long questions short — owner round, 2026-10-05). */}
|
||||||
|
<div className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"}
|
{kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"}
|
||||||
{qd.mode === "multi-select" && " · select all that apply"}
|
{qd.mode === "multi-select" && " · select all that apply"}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ describe("PracticeDock", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
assert.match(html, /Show that the sum is even\./);
|
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, /Your thinking/);
|
||||||
assert.match(html, /data-testid="practice-nudge"/);
|
assert.match(html, /data-testid="practice-nudge"/);
|
||||||
assert.match(html, /data-testid="practice-commit"/);
|
assert.match(html, /data-testid="practice-commit"/);
|
||||||
|
|||||||
Reference in New Issue
Block a user