From c93ea657f97537da2c718de3eccae604ace97761 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 21:06:27 +0200 Subject: [PATCH] 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 --- app/src/ui/tutor/TutorTab.tsx | 124 +++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 31 deletions(-) diff --git a/app/src/ui/tutor/TutorTab.tsx b/app/src/ui/tutor/TutorTab.tsx index 8ea2c8f..f9952ed 100644 --- a/app/src/ui/tutor/TutorTab.tsx +++ b/app/src/ui/tutor/TutorTab.tsx @@ -42,6 +42,9 @@ import { Keyboard, useComposer } from "../keyboard/Keyboard.js"; import promptTemplate from "@prompt/tutor-system.md?raw"; import "./tutor.css"; +/* Within this many pixels of the bottom counts as "following along". */ +const STICK_PX = 80; + const KEEP_TURNS = 26; /* 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 // closure is a render behind, which is not good enough for a guard. const inFlight = useRef(false); - const logEnd = useRef(null); + const log = useRef(null); const input = useRef(null); const composer = useComposer(); @@ -134,14 +137,19 @@ export function TutorTab() { /* ── the transcript ── */ - const loadTurns = useCallback(async () => { + const readTurns = useCallback(async (): Promise => { const rows = await db.all<{ id: number; role: string; body: string }>( "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.length; + return rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body })); }, [db]); + const loadTurns = useCallback(async () => { + const rows = await readTurns(); + setTurns(rows); + return rows.length; + }, [readTurns]); + useEffect(() => { void loadTurns(); }, [loadTurns]); @@ -217,10 +225,21 @@ export function TutorTab() { { 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 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); @@ -237,13 +256,15 @@ export function TutorTab() { } } catch (err) { const e = err as SampleError; - setStreaming(null); if (e?.code === "cancelled") { if (e.text) { await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`); - await loadTurns(); + setTurns(await readTurns()); } + setStreaming(null); + setBusy(false); } else { + setStreaming(null); setError(e?.message ?? "The tutor could not be reached."); } } finally { @@ -252,7 +273,7 @@ export function TutorTab() { 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 @@ -322,9 +343,49 @@ export function TutorTab() { }; }, [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(() => { - logEnd.current?.scrollIntoView({ block: "end" }); - }, [turns, streaming]); + const el = log.current; + 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 ── */ @@ -363,10 +424,9 @@ export function TutorTab() { -
- {turns.map((t, i) => { +
+ {parsedTurns.map(({ turn: t, parsed }, i) => { const you = t.role === "user"; - const parsed = you ? null : parseMessage(t.body); return (
{you ? "나" : "선생님"} @@ -392,27 +452,28 @@ export function TutorTab() { ); })} - {streaming !== null && ( -
+ {/* One slot for the turn in flight, keyed so the node survives + 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 && ( +
선생님 -
- -
+ {streaming === null ? ( +
+ + + +
+ ) : ( +
+ +
+ )}
)} - {busy && streaming === null && ( -
- 선생님 -
- - - -
-
- )} - -
{error &&
{error}
} @@ -424,6 +485,7 @@ export function TutorTab() { rows={2} value={draft} placeholder="Ask 선생님 something…" + aria-label="Ask 선생님 something" onChange={(e) => { composer.onExternalInput(); setDraft(e.target.value);