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:
+121
-65
@@ -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<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 () => {
|
||||
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. */}
|
||||
<div className="flex h-dvh">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only
|
||||
@@ -600,45 +615,56 @@ export function Chat({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
composer={
|
||||
<Composer
|
||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||
// client request to abort (stop() would be a no-op lie).
|
||||
busy={localStream}
|
||||
value={text}
|
||||
onChange={setText}
|
||||
onSend={onSend}
|
||||
onStop={() => 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). */}
|
||||
<div aria-hidden data-testid="dock-spacer" style={{ height: dockReserve }} />
|
||||
</ConversationContent>
|
||||
{/* 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. */}
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
onReserve={setDockReserve}
|
||||
composer={
|
||||
<Composer
|
||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||
// client request to abort (stop() would be a no-op lie).
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<typeof findPendingToolCall>;
|
||||
/** 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<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
|
||||
// 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 (
|
||||
<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="bg-background pb-4">
|
||||
<div ref={blockRef} className="bg-background pb-4">
|
||||
<div className={DOCK}>
|
||||
<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"}>
|
||||
|
||||
Reference in New Issue
Block a user