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);