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:
@@ -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<string[]>([]);
|
||||
const [done, setDone] = useState<Pair[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -335,7 +338,7 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="task">
|
||||
<div className="task" data-spent={spent}>
|
||||
<div className="task-h">
|
||||
<span className="eyebrow">연습 · {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 className="task-f">
|
||||
<span className="left tnum">
|
||||
{filled.n} of {filled.of} filled in
|
||||
</span>
|
||||
<button className="btn sm" onClick={onSkip} disabled={disabled}>
|
||||
Skip · just talk
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
||||
Submit answers
|
||||
</button>
|
||||
</div>
|
||||
{spent ? (
|
||||
<div className="task-spent">exercise answered</div>
|
||||
) : (
|
||||
<div className="task-f">
|
||||
<span className="left tnum">
|
||||
{filled.n} of {filled.of} filled in
|
||||
</span>
|
||||
<button className="btn sm" onClick={onSkip} disabled={disabled}>
|
||||
Skip · just talk
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
||||
Submit answers
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const foot = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLTextAreaElement>(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() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
||||
<div className="chat-log" ref={log} onScroll={onScroll}>
|
||||
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
||||
const you = t.role === "user";
|
||||
return (
|
||||
@@ -435,19 +483,22 @@ export function TutorTab() {
|
||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||
</div>
|
||||
|
||||
{parsed?.task &&
|
||||
(isLast(i) ? (
|
||||
<TaskHost
|
||||
task={parsed.task}
|
||||
turnId={t.id}
|
||||
lookups={[...revealed]}
|
||||
disabled={busy}
|
||||
onSubmit={(message) => void send(message)}
|
||||
onSkip={() => void send("Let's skip that one and just talk.")}
|
||||
/>
|
||||
) : (
|
||||
<div className="task-spent">exercise answered</div>
|
||||
))}
|
||||
{/* 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 && (
|
||||
<TaskHost
|
||||
task={parsed.task}
|
||||
turnId={t.id}
|
||||
lookups={[...revealed]}
|
||||
disabled={busy || !isLast(i)}
|
||||
spent={!isLast(i)}
|
||||
onSubmit={(message) => void send(message)}
|
||||
onSkip={() => void send("Let's skip that one and just talk.")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -478,7 +529,7 @@ export function TutorTab() {
|
||||
|
||||
{error && <div className="callout warn chat-error">{error}</div>}
|
||||
|
||||
<div className="chat-foot">
|
||||
<div className="chat-foot" ref={foot}>
|
||||
<div className="chat-in">
|
||||
<textarea
|
||||
ref={input}
|
||||
|
||||
@@ -249,3 +249,17 @@
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user