fix(tutor): stop the chat flickering and shifting when a reply lands

Three separate causes, all measurable with a MutationObserver over a turn.

The reply vanished and came back. On completion the code cleared
`streaming` and only then wrote the turn, trimmed the transcript and read
it back -- three awaits during which the message was unmounted, the typing
dots took its place, and the log jumped by the message's height. The write
now happens first and the placeholder is swapped for the committed turn in
one render.

The dots blinked again underneath the finished reply, because `busy`
stayed set until the finally block: one render with the turn committed and
the pending slot still mounted with `streaming` back to null. All three
state changes are now in the same batch.

The log shook while streaming. scrollIntoView() scrolls every scrollable
ancestor, so each token nudged the page as well as the log, and it fired
unconditionally, so scrolling up to re-read something was undone by the
next token. It now writes scrollTop on the log alone, once per animation
frame, and only when the reader is already within 80px of the bottom.

Also: the pending bubble is one keyed node rather than two sibling
conditionals, so waiting-to-streaming no longer remounts; and the
transcript is parsed once per change instead of on every render -- every
keystroke in the composer was re-parsing up to 26 messages of blocks.

Measured before: 1 bubble-count drop, 2 log-height shrinks, page scrolled
per token. After: 0, 0, and the page never moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 21:06:27 +02:00
parent 7f2762aeb8
commit c93ea657f9

View File

@@ -42,6 +42,9 @@ import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
import promptTemplate from "@prompt/tutor-system.md?raw"; import promptTemplate from "@prompt/tutor-system.md?raw";
import "./tutor.css"; import "./tutor.css";
/* Within this many pixels of the bottom counts as "following along". */
const STICK_PX = 80;
const KEEP_TURNS = 26; const KEEP_TURNS = 26;
/* The seven modes from FOCUS_MODES, with labels for the picker. Listed /* The seven modes from FOCUS_MODES, with labels for the picker. Listed
@@ -104,7 +107,7 @@ export function TutorTab() {
// `busy` drives the UI; `inFlight` guards re-entry. State read from a // `busy` drives the UI; `inFlight` guards re-entry. State read from a
// 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 logEnd = useRef<HTMLDivElement>(null); const log = useRef<HTMLDivElement>(null);
const input = useRef<HTMLTextAreaElement>(null); const input = useRef<HTMLTextAreaElement>(null);
const composer = useComposer(); const composer = useComposer();
@@ -134,14 +137,19 @@ export function TutorTab() {
/* ── the transcript ── */ /* ── the transcript ── */
const loadTurns = useCallback(async () => { const readTurns = useCallback(async (): Promise<Turn[]> => {
const rows = await db.all<{ id: number; role: string; body: string }>( const rows = await db.all<{ id: number; role: string; body: string }>(
"SELECT id, role, body FROM chat ORDER BY id", "SELECT id, role, body FROM chat ORDER BY id",
); );
setTurns(rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body }))); return rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body }));
return rows.length;
}, [db]); }, [db]);
const loadTurns = useCallback(async () => {
const rows = await readTurns();
setTurns(rows);
return rows.length;
}, [readTurns]);
useEffect(() => { useEffect(() => {
void loadTurns(); void loadTurns();
}, [loadTurns]); }, [loadTurns]);
@@ -217,10 +225,21 @@ export function TutorTab() {
{ signal: controller.signal, onText: ({ text }) => setStreaming(text) }, { signal: controller.signal, onText: ({ text }) => setStreaming(text) },
); );
setStreaming(null); /* Write first, then swap the placeholder for the real turn in a
single render. Clearing `streaming` before these three awaits
unmounted the message, fell back to the typing dots, and
remounted it once the read returned — the reply visibly
disappeared and the log jumped by its height each time. */
await editChatTurn(db, "assistant", result.text); await editChatTurn(db, "assistant", result.text);
await editChatTrim(db, KEEP_TURNS); await editChatTrim(db, KEEP_TURNS);
await loadTurns(); const committed = await readTurns();
/* All three in one batch. Leaving `busy` set until the finally
block meant one render where the reply was committed but the
pending slot was still mounted with `streaming` back to null —
the typing dots blinked underneath the finished message. */
setTurns(committed);
setStreaming(null);
setBusy(false);
const parsed = parseMessage(result.text); const parsed = parseMessage(result.text);
@@ -237,13 +256,15 @@ export function TutorTab() {
} }
} catch (err) { } catch (err) {
const e = err as SampleError; const e = err as SampleError;
setStreaming(null);
if (e?.code === "cancelled") { if (e?.code === "cancelled") {
if (e.text) { if (e.text) {
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`); await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
await loadTurns(); setTurns(await readTurns());
} }
setStreaming(null);
setBusy(false);
} else { } else {
setStreaming(null);
setError(e?.message ?? "The tutor could not be reached."); setError(e?.message ?? "The tutor could not be reached.");
} }
} finally { } finally {
@@ -252,7 +273,7 @@ export function TutorTab() {
setBusy(false); setBusy(false);
} }
}, },
[db, gate, loadTurns, prefs.focus, progress, recent, refreshProgress, sample, turns], [db, gate, loadTurns, prefs.focus, progress, readTurns, recent, refreshProgress, sample, turns],
); );
/* `send` is rebuilt on every render because it closes over the gate, the /* `send` is rebuilt on every render because it closes over the gate, the
@@ -322,9 +343,49 @@ export function TutorTab() {
}; };
}, [db, lastTutor, parsedLast]); }, [db, lastTutor, parsedLast]);
/* Autoscroll, but only the log and only when the learner is already at
the bottom.
scrollIntoView() scrolls every scrollable ancestor, so each token also
nudged the page itself — the shake. And it fired unconditionally, so
scrolling up to re-read an earlier turn was undone by the next token.
Setting scrollTop on the log moves nothing else; the threshold leaves
a reader who has scrolled away alone. */
/* Every keystroke in the composer re-renders this tab, and the transcript
was re-parsed from scratch each time — up to KEEP_TURNS messages of
block parsing per character typed. Parse once per transcript instead. */
const parsedTurns = useMemo(
() =>
turns.map((t) => ({
turn: t,
parsed: t.role === "user" ? null : parseMessage(t.body),
})),
[turns],
);
/* parseMessage() re-parsed the entire partial reply on every token, and
on every unrelated re-render of this tab. */
const streamingBody = useMemo(
() => (streaming === null ? "" : parseMessage(streaming).body),
[streaming],
);
const stick = useRef(true);
const onLogScroll = useCallback(() => {
const el = log.current;
if (el) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX;
}, []);
useEffect(() => { useEffect(() => {
logEnd.current?.scrollIntoView({ block: "end" }); const el = log.current;
}, [turns, streaming]); if (!el || !stick.current) return;
// One write per frame: setStreaming fires per token, and a scroll
// write per token is what makes the log judder while a reply lands.
const id = requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
return () => cancelAnimationFrame(id);
}, [turns, streaming, busy]);
/* ── rendering ── */ /* ── rendering ── */
@@ -363,10 +424,9 @@ export function TutorTab() {
</span> </span>
</div> </div>
<div className="chat-log"> <div className="chat-log" ref={log} onScroll={onLogScroll}>
{turns.map((t, i) => { {parsedTurns.map(({ turn: t, parsed }, i) => {
const you = t.role === "user"; const you = t.role === "user";
const parsed = you ? null : parseMessage(t.body);
return ( return (
<div className={`msg${you ? " you" : ""}`} key={t.id}> <div className={`msg${you ? " you" : ""}`} key={t.id}>
<span className="who ko">{you ? "나" : "선생님"}</span> <span className="who ko">{you ? "나" : "선생님"}</span>
@@ -392,27 +452,28 @@ export function TutorTab() {
); );
})} })}
{streaming !== null && ( {/* One slot for the turn in flight, keyed so the node survives
<div className="msg"> the change from waiting to streaming. As two sibling
conditionals the dots unmounted and the text mounted in
their place, which read as a blink at the moment the first
token arrived. */}
{busy && (
<div className="msg" key="pending">
<span className="who ko"></span> <span className="who ko"></span>
<div className="bubble"> {streaming === null ? (
<MessageBody text={parseMessage(streaming).body} /> <div className="bubble dots">
</div> <i />
<i />
<i />
</div>
) : (
<div className="bubble">
<MessageBody text={streamingBody} />
</div>
)}
</div> </div>
)} )}
{busy && streaming === null && (
<div className="msg">
<span className="who ko"></span>
<div className="bubble dots">
<i />
<i />
<i />
</div>
</div>
)}
<div ref={logEnd} />
</div> </div>
{error && <div className="callout warn chat-error">{error}</div>} {error && <div className="callout warn chat-error">{error}</div>}
@@ -424,6 +485,7 @@ export function TutorTab() {
rows={2} rows={2}
value={draft} value={draft}
placeholder="Ask 선생님 something…" placeholder="Ask 선생님 something…"
aria-label="Ask 선생님 something"
onChange={(e) => { onChange={(e) => {
composer.onExternalInput(); composer.onExternalInput();
setDraft(e.target.value); setDraft(e.target.value);