From 821622d148f07b6bdfcab7a7e2a163c1ddcd6ce3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 21:33:05 +0200 Subject: [PATCH] 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 --- app/src/ui/tutor/TaskHost.tsx | 33 ++++++----- app/src/ui/tutor/TutorTab.tsx | 105 +++++++++++++++++++++++++--------- app/src/ui/tutor/task.css | 14 +++++ 3 files changed, 112 insertions(+), 40 deletions(-) diff --git a/app/src/ui/tutor/TaskHost.tsx b/app/src/ui/tutor/TaskHost.tsx index 8a16375..a208471 100644 --- a/app/src/ui/tutor/TaskHost.tsx +++ b/app/src/ui/tutor/TaskHost.tsx @@ -49,6 +49,9 @@ export interface TaskProps { onSubmit: (message: string) => void; onSkip: () => void; 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 ───────────────────────────────────────────────────── */ @@ -293,7 +296,7 @@ function Choice({ task, picks, setPicks, disabled }: { /* ── 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([]); const [done, setDone] = useState([]); const [selected, setSelected] = useState(null); @@ -335,7 +338,7 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f }; return ( -
+
연습 · {task.type} {LABEL[task.type]} @@ -376,17 +379,21 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f )}
-
- - {filled.n} of {filled.of} filled in - - - -
+ {spent ? ( +
exercise answered
+ ) : ( +
+ + {filled.n} of {filled.of} filled in + + + +
+ )}
); } diff --git a/app/src/ui/tutor/TutorTab.tsx b/app/src/ui/tutor/TutorTab.tsx index f9952ed..fd32d04 100644 --- a/app/src/ui/tutor/TutorTab.tsx +++ b/app/src/ui/tutor/TutorTab.tsx @@ -108,6 +108,7 @@ export function TutorTab() { // closure is a render behind, which is not good enough for a guard. const inFlight = useRef(false); const log = useRef(null); + const foot = useRef(null); const input = useRef(null); const composer = useComposer(); @@ -365,27 +366,74 @@ export function TutorTab() { /* 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 streamingBody = useMemo(() => { + if (streaming === null) return ""; + /* 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 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; - 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(() => { - 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. + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, [onScroll]); + + 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(() => { - 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); - }, [turns, streaming, busy]); + }, [turns, streaming, busy, scroller]); /* ── rendering ── */ @@ -424,7 +472,7 @@ export function TutorTab() {
-
+
{parsedTurns.map(({ turn: t, parsed }, i) => { const you = t.role === "user"; return ( @@ -435,19 +483,22 @@ export function TutorTab() { {parsed?.gloss && }
- {parsed?.task && - (isLast(i) ? ( - void send(message)} - onSkip={() => void send("Let's skip that one and just talk.")} - /> - ) : ( -
exercise answered
- ))} + {/* An answered exercise stays rendered, read-only. It used + to collapse to a single line, which threw away what the + learner had typed and dropped ~170px out of the log the + instant they pressed Send — the largest single jump in + the whole view. */} + {parsed?.task && ( + void send(message)} + onSkip={() => void send("Let's skip that one and just talk.")} + /> + )}
); })} @@ -478,7 +529,7 @@ export function TutorTab() { {error &&
{error}
} -
+