Teach questions arrive collapsed in their first paint — no phantom scroll

Owner report (2026-09-25): in the teaching phase a new question arrives
collapsed behind the reveal button, but the transcript scrolled up as if
it had OPENED — the room it made was the expanded form's height, never
shown. Root cause: the expanded state is stale-true from the previous
question (nothing resets it when the dock returns to the composer), so
the new question's FIRST paint rendered open; the dock measured itself
tall and offered that height to the spacer, and the collapse one tick
later could never give it back (fold immunity refuses drops while a
question pends). Collapsed bar, tall reserve — scroll as-if-opened.

Fix (owner-shaped, deliberately minimal): apply the arrival state DURING
RENDER, before the first paint (the render-time state adjustment
pattern) — revealArrival(wasQuestion) decides how a teach question
lands: after the composer → collapsed (the learner reads the teaching
first); after ANOTHER question (a queued parallel check) → OPEN — the
reading already happened, and the height it already has is the honest
height. The paint measures the truth, so the reserve never learns a
height that isn't on screen. Probe arrivals are untouched — instant-open
at the bottom stays. The old [pendingId] effect keeps only its probe
branch.

Live-verified on :3001 with a fabricated teach-phase session driven
through the real settle/adoption path (zero model calls): composer→teach
arrival lands as the 59px bar with the spacer growing 206→211 (+5) and
the transcript scrolling only 77px (the new message itself) — the
previous behavior jumped the reserve +339px and scrolled 660px;
teach-check reveal click still expands (425px) and scrolls to bottom;
a queued parallel question arrives open with the reserve unchanged
(577→577) and +40px scroll.

Pins: tests/reveal-arrival.test.ts — revealArrival(false) = collapsed,
revealArrival(true) = open (the owner ruling). 946/946.
This commit is contained in:
amosblomqvist
2026-09-25 04:15:25 +03:00
parent d1542cdcbf
commit 5f5fa32482
2 changed files with 57 additions and 8 deletions
+36 -8
View File
@@ -1228,6 +1228,18 @@ export function Chat({
* stays MOUNTED while collapsed (hidden, not unmounted) so a half-made
* selection survives.
*/
/**
* How a teach-phase question arrives (owner ruling, 2026-09-25): collapsed
* behind the reveal button when it follows the composer — the learner
* reads the teaching first — but OPEN when it follows another question
* (a queued parallel check): the reading already happened, so there is
* nothing left to wait for and the scroll height it already had is the
* honest height.
*/
export function revealArrival(wasQuestion: boolean): { expanded: boolean; revealed: boolean } {
return wasQuestion ? { expanded: true, revealed: true } : { expanded: false, revealed: false };
}
function DockOverlay({
pending,
requiresReveal,
@@ -1286,18 +1298,34 @@ function DockOverlay({
const pendingId = pending?.toolCallId ?? null;
const initialPendingRef = useRef(pendingId);
// Teach-question ARRIVAL state, applied during render — BEFORE the
// first paint (owner, scroll-honesty round 2026-09-25). Until now the
// stale expanded=true left by the previous question painted one frame
// of the OPEN question: the dock measured itself tall, permanently
// reserved that height, and the collapse after the paint could never
// give it back (fold immunity) — so a collapsed teach question scrolled
// the transcript as if it had opened. Resetting here means the paint
// measures the bar, the reserve keeps the composer's height, and the
// only motion is the teaching text itself.
const prevPendingIdRef = useRef(pendingId);
if (pendingId !== prevPendingIdRef.current) {
const wasQuestion = prevPendingIdRef.current !== null;
prevPendingIdRef.current = pendingId;
if (pendingId && requiresReveal) {
const arrival = revealArrival(wasQuestion);
setExpanded(arrival.expanded);
setRevealed(arrival.revealed);
}
}
// New question MID-SESSION: open expanded if the learner is following at
// the bottom (scroll to reveal it); reading history → arrive as the bar.
// Teach-phase checks ALWAYS arrive collapsed behind the reveal button,
// even at the bottom. On session mount the dock simply starts in its
// initial state (expanded for probe, invitation for teach) — no scroll, no
// morph.
// Teach-phase arrivals are handled above, during render. On session
// mount the dock simply starts in its initial state (expanded for
// probe, invitation for teach) — no scroll, no morph.
useEffect(() => {
if (!pendingId || pendingId === initialPendingRef.current) return;
if (requiresReveal) {
setRevealed(false);
setExpanded(false);
} else {
if (!requiresReveal) {
setExpanded(isAtBottom);
if (isAtBottom) void scrollToBottom();
}