fix(tutor): the rest of the chat movement, found by measuring properly

The previous pass measured with a 50ms timer and reported zero. A
MutationObserver plus the Layout Shift API disagreed: three log-height
shrinks per turn and two places where the reply's text went backwards.
Timer sampling had simply missed the frames.

Raw markup was flashing. A directive line arrives before the block it
opens is complete, and until then parse() has no reason to treat it as
anything but prose -- so "::task match" and "::words" rendered as visible
text for one frame and then vanished when the next line closed the block.
The streaming preview now drops :: lines outright, since blocks are not
rendered until the turn commits. It also trims the blank line that
preceded the directive, which MessageBody renders as a 9px gap: that gap
opened and closed on every block boundary.

The answered exercise no longer collapses. It was replaced by a one-line
"exercise answered" the instant Send was pressed -- around 170px out of
the log in a single frame, the largest jump in the view, and it threw away
what the learner had typed. It now stays rendered read-only, dimmed, with
the answers still legible.

Autoscroll never worked on a phone. Below 900px tutor.css sets the log's
max-height to none, so it has no internal scroll and setting scrollTop did
nothing -- a new reply just landed below the fold. It now scrolls whichever
element is actually the scroller. In the page-flow case the target is the
composer, not the bottom of the document: the word rail renders below the
chat, so following the document would scroll past the whole rail on every
turn. A reader who has scrolled up is still left alone.

Measured per turn, desktop: text regressions 2 -> 0, log-height shrinks
3 -> 0, DOM mutations 9 -> 5, CLS 0.0026 (only the word rail's own footer,
which is real content changing). Mobile: the composer stays in view and
the newest message is on screen; scrolled-up reader unmoved at y=200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 21:33:05 +02:00
parent 0b04931699
commit 821622d148
3 changed files with 112 additions and 40 deletions

View File

@@ -49,6 +49,9 @@ export interface TaskProps {
onSubmit: (message: string) => void; onSubmit: (message: string) => void;
onSkip: () => void; onSkip: () => void;
disabled?: boolean; disabled?: boolean;
/** Already answered: keep the exercise and the answers on screen, but
show that it is finished rather than the submit controls. */
spent?: boolean;
} }
/* ── translate ───────────────────────────────────────────────────── */ /* ── translate ───────────────────────────────────────────────────── */
@@ -293,7 +296,7 @@ function Choice({ task, picks, setPicks, disabled }: {
/* ── host ────────────────────────────────────────────────────────── */ /* ── host ────────────────────────────────────────────────────────── */
export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = false }: TaskProps) { export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = false, spent = false }: TaskProps) {
const [answers, setAnswers] = useState<string[]>([]); const [answers, setAnswers] = useState<string[]>([]);
const [done, setDone] = useState<Pair[]>([]); const [done, setDone] = useState<Pair[]>([]);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
@@ -335,7 +338,7 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
}; };
return ( return (
<div className="task"> <div className="task" data-spent={spent}>
<div className="task-h"> <div className="task-h">
<span className="eyebrow"> · {task.type}</span> <span className="eyebrow"> · {task.type}</span>
<span className="hint">{LABEL[task.type]}</span> <span className="hint">{LABEL[task.type]}</span>
@@ -376,17 +379,21 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
)} )}
</div> </div>
<div className="task-f"> {spent ? (
<span className="left tnum"> <div className="task-spent">exercise answered</div>
{filled.n} of {filled.of} filled in ) : (
</span> <div className="task-f">
<button className="btn sm" onClick={onSkip} disabled={disabled}> <span className="left tnum">
Skip · just talk {filled.n} of {filled.of} filled in
</button> </span>
<button className="btn sm primary" onClick={submit} disabled={disabled}> <button className="btn sm" onClick={onSkip} disabled={disabled}>
Submit answers Skip · just talk
</button> </button>
</div> <button className="btn sm primary" onClick={submit} disabled={disabled}>
Submit answers
</button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -108,6 +108,7 @@ export function TutorTab() {
// closure is a render behind, which is not good enough for a guard. // closure is a render behind, which is not good enough for a guard.
const inFlight = useRef(false); const inFlight = useRef(false);
const log = useRef<HTMLDivElement>(null); const log = useRef<HTMLDivElement>(null);
const foot = useRef<HTMLDivElement>(null);
const input = useRef<HTMLTextAreaElement>(null); const input = useRef<HTMLTextAreaElement>(null);
const composer = useComposer(); const composer = useComposer();
@@ -365,27 +366,74 @@ export function TutorTab() {
/* parseMessage() re-parsed the entire partial reply on every token, and /* parseMessage() re-parsed the entire partial reply on every token, and
on every unrelated re-render of this tab. */ on every unrelated re-render of this tab. */
const streamingBody = useMemo( const streamingBody = useMemo(() => {
() => (streaming === null ? "" : parseMessage(streaming).body), if (streaming === null) return "";
[streaming], /* A directive line arrives before the block it opens is complete, and
); until then parse() has no reason to treat it as anything but prose —
so "::task match" and "::words" rendered as visible text for exactly
one frame and then vanished. That blink was the flicker. Blocks are
not rendered during streaming anyway, so drop the markers outright
and the preview only ever grows. */
const lines = parseMessage(streaming)
.body.split("\n")
.filter((l) => !l.trimStart().startsWith("::"));
/* MessageBody renders a blank line as a 9px gap. Dropping a directive
leaves the blank line that preceded it at the end of the preview, so
a gap opened and closed on every block boundary. */
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
return lines.join("\n");
}, [streaming]);
const stick = useRef(true); const stick = useRef(true);
const onLogScroll = useCallback(() => {
/* Which element actually scrolls depends on the width. Above 900px the
log is a fixed-height scroller; below it, tutor.css sets max-height to
none and the window scrolls instead. The autoscroll only ever handled
the first case, so on a phone nothing followed the reply at all — a
new message simply landed below the fold. */
const scroller = useCallback((): HTMLElement | null => {
const el = log.current; const el = log.current;
if (el) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX; if (!el) return null;
return el.scrollHeight > el.clientHeight + 1 ? el : null;
}, []); }, []);
/* In the page-flow layout the end of the conversation is NOT the end of
the document — the word rail is rendered below the chat panel — so the
thing to keep in view is the composer, which sits directly under the
log. Following the document's bottom would mean scrolling past the
whole rail on every turn. */
const footBottom = () =>
foot.current ? foot.current.getBoundingClientRect().bottom - window.innerHeight : 0;
const atBottom = useCallback(() => {
const el = scroller();
if (el) return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX;
return footBottom() < STICK_PX;
}, [scroller]);
const onScroll = useCallback(() => {
stick.current = atBottom();
}, [atBottom]);
useEffect(() => { useEffect(() => {
const el = log.current; window.addEventListener("scroll", onScroll, { passive: true });
if (!el || !stick.current) return; return () => window.removeEventListener("scroll", onScroll);
// One write per frame: setStreaming fires per token, and a scroll }, [onScroll]);
// write per token is what makes the log judder while a reply lands.
useEffect(() => {
if (!stick.current) return;
// One write per frame: setStreaming fires per token, and a scroll write
// per token is what makes the view judder while a reply lands.
const id = requestAnimationFrame(() => { const id = requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight; const el = scroller();
if (el) el.scrollTop = el.scrollHeight;
else {
const by = footBottom();
if (by > 0) window.scrollBy(0, by);
}
}); });
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [turns, streaming, busy]); }, [turns, streaming, busy, scroller]);
/* ── rendering ── */ /* ── rendering ── */
@@ -424,7 +472,7 @@ export function TutorTab() {
</span> </span>
</div> </div>
<div className="chat-log" ref={log} onScroll={onLogScroll}> <div className="chat-log" ref={log} onScroll={onScroll}>
{parsedTurns.map(({ turn: t, parsed }, i) => { {parsedTurns.map(({ turn: t, parsed }, i) => {
const you = t.role === "user"; const you = t.role === "user";
return ( return (
@@ -435,19 +483,22 @@ export function TutorTab() {
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />} {parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
</div> </div>
{parsed?.task && {/* An answered exercise stays rendered, read-only. It used
(isLast(i) ? ( to collapse to a single line, which threw away what the
<TaskHost learner had typed and dropped ~170px out of the log the
task={parsed.task} instant they pressed Send — the largest single jump in
turnId={t.id} the whole view. */}
lookups={[...revealed]} {parsed?.task && (
disabled={busy} <TaskHost
onSubmit={(message) => void send(message)} task={parsed.task}
onSkip={() => void send("Let's skip that one and just talk.")} turnId={t.id}
/> lookups={[...revealed]}
) : ( disabled={busy || !isLast(i)}
<div className="task-spent">exercise answered</div> spent={!isLast(i)}
))} onSubmit={(message) => void send(message)}
onSkip={() => void send("Let's skip that one and just talk.")}
/>
)}
</div> </div>
); );
})} })}
@@ -478,7 +529,7 @@ export function TutorTab() {
{error && <div className="callout warn chat-error">{error}</div>} {error && <div className="callout warn chat-error">{error}</div>}
<div className="chat-foot"> <div className="chat-foot" ref={foot}>
<div className="chat-in"> <div className="chat-in">
<textarea <textarea
ref={input} ref={input}

View File

@@ -249,3 +249,17 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* A finished exercise stays on screen so the learner can see what they
answered — collapsing it dropped ~170px out of the log the instant they
pressed Send. It has to read as finished rather than as one more thing
waiting for input, so the whole block steps back. */
.task[data-spent="true"] {
opacity: 0.72;
}
.task[data-spent="true"] input:disabled,
.task[data-spent="true"] textarea:disabled {
background: var(--sunk);
color: var(--ink2);
}