From 5f5fa3248218cec351de489f39f14edaf976062a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 25 Sep 2026 04:15:25 +0300 Subject: [PATCH] =?UTF-8?q?Teach=20questions=20arrive=20collapsed=20in=20t?= =?UTF-8?q?heir=20first=20paint=20=E2=80=94=20no=20phantom=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/chat.tsx | 44 +++++++++++++++++++++++++++++------- tests/reveal-arrival.test.ts | 21 +++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 tests/reveal-arrival.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index fd5b564..bb16355 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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(); } diff --git a/tests/reveal-arrival.test.ts b/tests/reveal-arrival.test.ts new file mode 100644 index 0000000..736a2e6 --- /dev/null +++ b/tests/reveal-arrival.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { revealArrival } from "../components/chat.tsx"; + +/** + * Teach-question arrival states (owner ruling, scroll-honesty round + * 2026-09-25). Applied DURING RENDER before the first paint — the bug was + * the stale expanded=true left by the previous question painting one + * frame of the open question: the dock measured itself tall, reserved that + * height permanently (fold immunity refused the give-back), and the + * transcript scrolled as if the collapsed question had opened. + */ +describe("revealArrival — how a teach question lands", () => { + it("fresh arrival after the composer: collapsed behind the reveal button", () => { + assert.deepEqual(revealArrival(false), { expanded: false, revealed: false }); + }); + + it("queued behind another question: open — the reading already happened", () => { + assert.deepEqual(revealArrival(true), { expanded: true, revealed: true }); + }); +}); \ No newline at end of file