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:
+36
-8
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user