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;
|
onSubmit: (message: string) => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
disabled?: boolean;
|
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 ───────────────────────────────────────────────────── */
|
/* ── translate ───────────────────────────────────────────────────── */
|
||||||
@@ -293,7 +296,7 @@ function Choice({ task, picks, setPicks, disabled }: {
|
|||||||
|
|
||||||
/* ── host ────────────────────────────────────────────────────────── */
|
/* ── 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 [answers, setAnswers] = useState<string[]>([]);
|
||||||
const [done, setDone] = useState<Pair[]>([]);
|
const [done, setDone] = useState<Pair[]>([]);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
@@ -335,7 +338,7 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task">
|
<div className="task" data-spent={spent}>
|
||||||
<div className="task-h">
|
<div className="task-h">
|
||||||
<span className="eyebrow">연습 · {task.type}</span>
|
<span className="eyebrow">연습 · {task.type}</span>
|
||||||
<span className="hint">{LABEL[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>
|
||||||
|
|
||||||
<div className="task-f">
|
{spent ? (
|
||||||
<span className="left tnum">
|
<div className="task-spent">exercise answered</div>
|
||||||
{filled.n} of {filled.of} filled in
|
) : (
|
||||||
</span>
|
<div className="task-f">
|
||||||
<button className="btn sm" onClick={onSkip} disabled={disabled}>
|
<span className="left tnum">
|
||||||
Skip · just talk
|
{filled.n} of {filled.of} filled in
|
||||||
</button>
|
</span>
|
||||||
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
<button className="btn sm" onClick={onSkip} disabled={disabled}>
|
||||||
Submit answers
|
Skip · just talk
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
||||||
|
Submit answers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function TutorTab() {
|
|||||||
// 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 log = useRef<HTMLDivElement>(null);
|
const log = useRef<HTMLDivElement>(null);
|
||||||
|
const foot = useRef<HTMLDivElement>(null);
|
||||||
const input = useRef<HTMLTextAreaElement>(null);
|
const input = useRef<HTMLTextAreaElement>(null);
|
||||||
const composer = useComposer();
|
const composer = useComposer();
|
||||||
|
|
||||||
@@ -365,27 +366,74 @@ export function TutorTab() {
|
|||||||
|
|
||||||
/* parseMessage() re-parsed the entire partial reply on every token, and
|
/* parseMessage() re-parsed the entire partial reply on every token, and
|
||||||
on every unrelated re-render of this tab. */
|
on every unrelated re-render of this tab. */
|
||||||
const streamingBody = useMemo(
|
const streamingBody = useMemo(() => {
|
||||||
() => (streaming === null ? "" : parseMessage(streaming).body),
|
if (streaming === null) return "";
|
||||||
[streaming],
|
/* 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 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;
|
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(() => {
|
useEffect(() => {
|
||||||
const el = log.current;
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
if (!el || !stick.current) return;
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
// One write per frame: setStreaming fires per token, and a scroll
|
}, [onScroll]);
|
||||||
// write per token is what makes the log judder while a reply lands.
|
|
||||||
|
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(() => {
|
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);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [turns, streaming, busy]);
|
}, [turns, streaming, busy, scroller]);
|
||||||
|
|
||||||
/* ── rendering ── */
|
/* ── rendering ── */
|
||||||
|
|
||||||
@@ -424,7 +472,7 @@ export function TutorTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
<div className="chat-log" ref={log} onScroll={onScroll}>
|
||||||
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
||||||
const you = t.role === "user";
|
const you = t.role === "user";
|
||||||
return (
|
return (
|
||||||
@@ -435,19 +483,22 @@ export function TutorTab() {
|
|||||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parsed?.task &&
|
{/* An answered exercise stays rendered, read-only. It used
|
||||||
(isLast(i) ? (
|
to collapse to a single line, which threw away what the
|
||||||
<TaskHost
|
learner had typed and dropped ~170px out of the log the
|
||||||
task={parsed.task}
|
instant they pressed Send — the largest single jump in
|
||||||
turnId={t.id}
|
the whole view. */}
|
||||||
lookups={[...revealed]}
|
{parsed?.task && (
|
||||||
disabled={busy}
|
<TaskHost
|
||||||
onSubmit={(message) => void send(message)}
|
task={parsed.task}
|
||||||
onSkip={() => void send("Let's skip that one and just talk.")}
|
turnId={t.id}
|
||||||
/>
|
lookups={[...revealed]}
|
||||||
) : (
|
disabled={busy || !isLast(i)}
|
||||||
<div className="task-spent">exercise answered</div>
|
spent={!isLast(i)}
|
||||||
))}
|
onSubmit={(message) => void send(message)}
|
||||||
|
onSkip={() => void send("Let's skip that one and just talk.")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -478,7 +529,7 @@ export function TutorTab() {
|
|||||||
|
|
||||||
{error && <div className="callout warn chat-error">{error}</div>}
|
{error && <div className="callout warn chat-error">{error}</div>}
|
||||||
|
|
||||||
<div className="chat-foot">
|
<div className="chat-foot" ref={foot}>
|
||||||
<div className="chat-in">
|
<div className="chat-in">
|
||||||
<textarea
|
<textarea
|
||||||
ref={input}
|
ref={input}
|
||||||
|
|||||||
@@ -249,3 +249,17 @@
|
|||||||
grid-template-columns: 1fr;
|
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