The reworked artifact went mobile-first; this ports its shell, minus the
quirks it shipped with.
Routes. #lesson #today #settings #words #learn #sent #grammar #cj #hangul
#drill, each a history entry, so the phone's back gesture works. The word
sheet, a popover and the review screen are layers: each owns an entry while
open, and Back closes the topmost before it leaves a route — the artifact
read window.__onBack but never set it. shell/history.ts holds the rules,
free of React and tested against a history whose traversals land late, as a
browser's do. Unlike the artifact's router:
· re-tapping the current destination pushes nothing;
· leaving a route closes what is open on it, rewinding its entries first;
· a sub-page's ← goes back only when history leads to its parent, and
otherwise becomes the parent — history.length > 1 let ← leave the app.
Shell. The page never scrolls; each route owns one scroller, in svh. A
bottom bar under 600px, a 76px rail to 840px, a labelled rail beyond, with
the artifact's icons and a due badge on 복습. Routes mount on first visit and
stay mounted, so a draft or a drill survives a trip elsewhere. Settings,
the conjugation trainer and the reading drill are pages of their own; 학습
is a hub. The design tokens gain the layout set and a second register:
reference panels stay square, what a thumb works is rounded.
복습 is its own screen: tap anywhere to reveal, grades in the thumb zone,
and an empty queue says so instead of doing nothing. Its pool is "my
units" once there are twenty such words, as in the artifact — plus his own
words, which the artifact dropped from review at that point.
The lesson is a column that fits the screen: roadmap strip, conversation,
composer with the quick-reply chips, and a ··· menu for focus and clearing.
The word list is docked from 840px; below that it is a sheet with peek,
half and full detents and a drag handle, whose height comes off the shell
so the exercise above shrinks rather than being covered. Focusing an answer
drops it to peek; a new exercise takes it down from full.
Fixed on the way: the log now follows a reply by what the learner did, not
by distance — its own scroll event arrived after the stream had added more
than the threshold, which read as scrolling away and stopped the follow
mid-reply. Sending re-sticks. The keyboard's focus guard moves to
pointerdown; React 19 attaches touchstart passively, so preventDefault()
there was ignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
805 lines
30 KiB
TypeScript
805 lines
30 KiB
TypeScript
/* The lesson.
|
|
|
|
This is where the curriculum, the dictionary and the prompt meet:
|
|
|
|
progress + curriculum + met words -> buildGate() -> {{GATE}}
|
|
|
|
|
prompt/tutor-system.md + this round's tail
|
|
|
|
|
runTurn(): sample -> scan -> retry at most twice
|
|
|
|
|
applyReply(): ::progress (earned) · ::result (evidence) · ::confirmed
|
|
|
|
The transcript lives in the `chat` table, so the client owns it. The turn
|
|
itself — what the tutor may say and what its marking may change — lives
|
|
in domain/turn.ts, where it is testable without React.
|
|
|
|
The layout is a column that fits the screen exactly: the roadmap strip,
|
|
the conversation (the one thing that scrolls), and the composer. The
|
|
word list is a docked column at 840px and up, and a sheet below that. */
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useStore } from "../../state/store.js";
|
|
import { editChatClear, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js";
|
|
import { parseMessage } from "../../domain/gloss.js";
|
|
import {
|
|
gateFor,
|
|
metWords,
|
|
FOCUS_MODES,
|
|
UNITS,
|
|
VOCAB_CAP,
|
|
type BandQuery,
|
|
type FlatUnit,
|
|
} from "../../domain/gate.js";
|
|
import type { FocusMode } from "../../domain/gate.js";
|
|
import { currentUnit, noteAnswer } from "../../domain/progress.js";
|
|
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
|
|
import { makeRemoteTutor } from "../../domain/tutor-client.js";
|
|
import { lookupMany } from "../../domain/lexicon.js";
|
|
import { applyReply, readRecent, runTurn } from "../../domain/turn.js";
|
|
import { coverage } from "../../domain/ledger.js";
|
|
import type { Retry } from "../../domain/prompt-tail.js";
|
|
import { REFERENCE_BAND, bandForUnit, ceilingForBand } from "@shared/bands.mjs";
|
|
import type { Db } from "../../db/types.js";
|
|
import type { ParsedMessage } from "@lib/blocks.js";
|
|
|
|
import { MessageBody } from "./MessageBody.js";
|
|
import { GlossBlocks } from "./GlossBlock.js";
|
|
import { TaskHost } from "./TaskHost.js";
|
|
import { RailPanel, collectWords, type RailWord } from "./WordRail.js";
|
|
import { WordSheet, type Detent } from "./WordSheet.js";
|
|
import { RoadStrip } from "./RoadStrip.js";
|
|
import { Composer } from "./Composer.js";
|
|
import { RouteHead, useRouteActive } from "../shell/Route.js";
|
|
import { useLayer } from "../shell/router.js";
|
|
import { Pop } from "../shell/Pop.js";
|
|
import { MapIcon, SearchIcon } from "../shell/icons.js";
|
|
import { WIDE, useMedia } from "../shell/useMedia.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, labelled as the artifact labels them.
|
|
Listed rather than derived so the order is deliberate: auto first, free
|
|
last. */
|
|
const FOCUS_LABELS: [FocusMode, string][] = [
|
|
["auto", "자동 · 선생님 follows the roadmap"],
|
|
["sentence", "문장 논리 · Sentence logic"],
|
|
["vocab", "단어 · Vocabulary drilling"],
|
|
["particles", "조사 · Particles"],
|
|
["sound", "소리 · Sound changes"],
|
|
["manhwa", "만화 · Real manhwa lines"],
|
|
["free", "자유 · Whatever I ask"],
|
|
];
|
|
|
|
/* Every mode must exist in FOCUS_MODES, or {{FOCUS}} silently renders
|
|
nothing for it. */
|
|
void (FOCUS_LABELS satisfies [keyof typeof FOCUS_MODES, string][]);
|
|
|
|
interface Turn {
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
body: string;
|
|
}
|
|
|
|
/* ── the band query, synchronously available to buildGate ─────────── */
|
|
|
|
/**
|
|
* buildGate's vocabQuery is synchronous, so the band's words are read once
|
|
* per unit and handed over as a snapshot rather than queried inline.
|
|
*/
|
|
async function readBandWords(db: Db, band: number, ceiling: number): Promise<string[]> {
|
|
const rows = await db.all<{ headword: string }>(
|
|
`SELECT DISTINCT headword FROM lemma
|
|
WHERE unit_band <= ? AND unit_band < ?
|
|
AND (freq_rank IS NOT NULL AND freq_rank <= ?
|
|
OR source IN ('curated','grammar','sentence','sfx','curriculum'))
|
|
ORDER BY freq_rank IS NULL, freq_rank
|
|
LIMIT ?`,
|
|
[band, REFERENCE_BAND, ceiling, VOCAB_CAP * 3],
|
|
);
|
|
return rows.map((r) => r.headword);
|
|
}
|
|
|
|
/* ── the opening ─────────────────────────────────────────────────── */
|
|
|
|
/** The app's own first message — the artifact's wording. */
|
|
function openingTurn(unit: FlatUnit): string {
|
|
const first = unit.id === UNITS[0]!.id;
|
|
return [
|
|
"반가워요. 저는 선생님이에요 — your reading tutor.",
|
|
"",
|
|
first
|
|
? "We're starting at the beginning, and we'll go one small step at a time. Nothing will turn up in an exercise that I haven't taught you first — if it does, tell me and I'll drop it."
|
|
: "Picking up where you left off. Nothing will turn up in an exercise that I haven't taught you first — if it does, tell me and I'll drop it.",
|
|
"",
|
|
`The plan is six phases, ${UNITS.length} units, ending with you reading a manhwa page at speed. **Roadmap** above shows the whole thing; you can jump anywhere in it whenever you like.`,
|
|
"",
|
|
`Hit **시작 · Start unit** below, and we'll pick up ${unit.id} ${unit.ko} — ${unit.name}.`,
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* His own message, as he sees it. A recall answer carries the app's
|
|
* LETTER-LEVEL CHECK for the tutor — written to the model ("Use it exactly")
|
|
* — which stays in what is sent and is left out of his bubble.
|
|
*/
|
|
function ownWords(body: string): string {
|
|
const at = body.indexOf("\n\n════ LETTER-LEVEL CHECK");
|
|
if (at < 0) return body;
|
|
const rest = body.slice(at + 2);
|
|
const lookups = rest.search(/\n\n\((I had to look up|No lookups)/);
|
|
return body.slice(0, at) + (lookups >= 0 ? rest.slice(lookups) : "");
|
|
}
|
|
|
|
/** What the learner sees while a refused draft is being rewritten. */
|
|
function retryNote(r: Retry): string {
|
|
if (r.korean && !r.findings.length) return "선생님 answered in Korean — asking him again…";
|
|
const words = r.findings.map((f) => f.word).slice(0, 3).join(", ");
|
|
return `선생님 used a word he has not taught (${words}) — asking him again…`;
|
|
}
|
|
|
|
/* ── the lesson ──────────────────────────────────────────────────── */
|
|
|
|
export function TutorTab() {
|
|
const { db, progress, prefs, setPref, server, refreshProgress, invalidate, revision, today } = useStore();
|
|
const active = useRouteActive();
|
|
const wide = useMedia(WIDE);
|
|
|
|
const [turns, setTurns] = useState<Turn[]>([]);
|
|
const [streaming, setStreaming] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
|
const [railWords, setRailWords] = useState<RailWord[]>([]);
|
|
const [railQuery, setRailQuery] = useState("");
|
|
const [bandWords, setBandWords] = useState<string[]>([]);
|
|
const [keyboard, setKeyboard] = useState(false);
|
|
const [recent, setRecent] = useState<string[]>([]);
|
|
const [met, setMet] = useState<string[]>([]);
|
|
/** The line under the composer: a retry, an error, a confirmation. */
|
|
const [note, setNote] = useState<string | null>(null);
|
|
/** Words flagged in the last reply shown despite the gate; the next turn names them. */
|
|
const [strays, setStrays] = useState<string[]>([]);
|
|
/** Flagged words per turn id, so the flag stays under its own message. */
|
|
const [flags, setFlags] = useState<Record<string, string[]>>({});
|
|
const [roadOpen, setRoadOpen] = useState(false);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [detent, setDetent] = useState<Detent>("closed");
|
|
|
|
/** What he had looked up when he submitted the answer being marked. */
|
|
const lastLookups = useRef<string[]>([]);
|
|
/** The 다지기 checklist rules still open — only the stand-in reads it. */
|
|
const openRules = useRef<string[]>([]);
|
|
|
|
const abort = useRef<AbortController | null>(null);
|
|
// `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 log = useRef<HTMLDivElement>(null);
|
|
/** The log follows new text while this is set; see the follow effect. */
|
|
const stick = useRef(true);
|
|
const menuButton = useRef<HTMLButtonElement>(null);
|
|
|
|
const unit = currentUnit(progress);
|
|
const unitId = progress.current;
|
|
|
|
/* The sheet is a layer below 840px. Leaving the route closes it, as does
|
|
growing past 840px, where the list is docked instead. */
|
|
const sheetOpen = !wide && active && detent !== "closed";
|
|
useLayer("sheet", sheetOpen, () => setDetent("closed"));
|
|
useEffect(() => {
|
|
if (wide) setDetent("closed");
|
|
}, [wide]);
|
|
|
|
/* ── the gate ── */
|
|
|
|
const bandQuery: BandQuery = useCallback(
|
|
() => bandWords.map((headword) => ({ headword })),
|
|
[bandWords],
|
|
);
|
|
|
|
const gate = useMemo(() => gateFor({ progress, bandQuery, met }), [progress, bandQuery, met]);
|
|
|
|
/* Words he has met allow themselves into the gate, so they are re-read
|
|
whenever cards may have changed — after a review or a marked answer. */
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void metWords(db).then((w) => {
|
|
if (!cancelled) setMet(w);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [db, revision, progress]);
|
|
|
|
useEffect(() => {
|
|
void readRecent(db).then(setRecent);
|
|
}, [db]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!unit.review) {
|
|
openRules.current = [];
|
|
return;
|
|
}
|
|
void coverage(db, unit.phase).then((c) => {
|
|
if (!cancelled) openRules.current = c.openRules;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [db, unit, revision]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
const band = bandForUnit(unitId);
|
|
const words = await readBandWords(db, band, ceilingForBand(band));
|
|
if (!cancelled) setBandWords(words);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [db, unitId]);
|
|
|
|
/* ── the transcript ── */
|
|
|
|
const readTurns = useCallback(async (): Promise<Turn[]> => {
|
|
const rows = await db.all<{ id: string; role: string; body: string }>(
|
|
"SELECT id, role, body FROM chat ORDER BY created_at, id",
|
|
);
|
|
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]);
|
|
|
|
/* ── the responder ── */
|
|
|
|
/* The unit's own new words, glossed — what the stub builds exercises from
|
|
and what the real tutor would be told it may introduce. */
|
|
const railVocabulary = useRef<StubWord[]>([]);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
const found = await lookupMany(db, gate.newWords.slice(0, 12));
|
|
if (cancelled) return;
|
|
railVocabulary.current = gate.newWords.slice(0, 12).flatMap<StubWord>((ko) => {
|
|
const hit = found.get(ko);
|
|
return hit ? [{ ko, gloss: hit.glossEn, note: hit.pos }] : [];
|
|
});
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [db, gate.newWords]);
|
|
|
|
const sample: Sample = useMemo(() => {
|
|
// A configured server means the real 선생님; otherwise the local stand-in,
|
|
// so the app is complete offline rather than degraded.
|
|
if (server) return makeRemoteTutor(server);
|
|
|
|
return makeStubTutor(() => ({
|
|
gate,
|
|
words: railVocabulary.current,
|
|
turn: turns.filter((t) => t.role === "user").length,
|
|
confidence: progress.confidence[progress.current] ?? 0,
|
|
openRules: openRules.current,
|
|
}));
|
|
}, [gate, turns, progress, server]);
|
|
|
|
/* ── sending ── */
|
|
|
|
const send = useCallback(
|
|
async (body: string, { lookups = [] }: { lookups?: string[] } = {}) => {
|
|
if (inFlight.current) return;
|
|
inFlight.current = true;
|
|
setBusy(true);
|
|
setNote("선생님 is reading your answer…");
|
|
|
|
// What he looked up belongs to THIS answer; a typed message has none.
|
|
lastLookups.current = lookups;
|
|
|
|
// Answering scrolled the log up to the fields; sending means he wants
|
|
// the reply, so follow it again.
|
|
stick.current = true;
|
|
|
|
// Counted before the request, as the artifact does: the answer is
|
|
// given whether or not the reply arrives.
|
|
await noteAnswer(db, progress, body);
|
|
await editChatTurn(db, "user", body);
|
|
await loadTurns();
|
|
|
|
const controller = new AbortController();
|
|
abort.current = controller;
|
|
|
|
try {
|
|
const result = await runTurn({
|
|
db,
|
|
sample,
|
|
template: promptTemplate,
|
|
gate,
|
|
progress,
|
|
focus: prefs.focus,
|
|
recent,
|
|
history: turns.map((t) => ({ role: t.role, content: t.body })),
|
|
message: body,
|
|
strays,
|
|
signal: controller.signal,
|
|
onText: setStreaming,
|
|
// A refused draft is never shown: back to the dots, with the reason.
|
|
onRetry: (r) => {
|
|
setStreaming(null);
|
|
setNote(retryNote(r));
|
|
},
|
|
});
|
|
|
|
/* Write first, then swap the placeholder for the real turn in a
|
|
single render. Clearing `streaming` before these 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 pruneChat(db, KEEP_TURNS);
|
|
const committed = await readTurns();
|
|
const flagged = result.findings.map((f) => f.word);
|
|
const replyId = committed[committed.length - 1]?.id;
|
|
/* All 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);
|
|
setNote(null);
|
|
setStrays(flagged);
|
|
if (replyId && flagged.length) setFlags((f) => ({ ...f, [replyId]: flagged }));
|
|
|
|
const applied = await applyReply(db, result.parsed, { lookups: lastLookups.current, today });
|
|
|
|
// A new exercise resets the per-exercise lookup set — that set is
|
|
// what the answer reports back, so it must not carry over — and
|
|
// takes the word list down from full, so the exercise is in view.
|
|
if (result.parsed.task) {
|
|
setRevealed(new Set());
|
|
setDetent((d) => (d === "full" ? "peek" : d));
|
|
}
|
|
setRecent(applied.recent);
|
|
await refreshProgress();
|
|
invalidate();
|
|
} catch (err) {
|
|
const e = err as SampleError;
|
|
if (e?.code === "cancelled") {
|
|
if (e.text) {
|
|
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
|
|
setTurns(await readTurns());
|
|
}
|
|
setStreaming(null);
|
|
setBusy(false);
|
|
setNote(null);
|
|
} else {
|
|
setStreaming(null);
|
|
setNote(e?.message ?? "The tutor could not be reached.");
|
|
}
|
|
} finally {
|
|
abort.current = null;
|
|
inFlight.current = false;
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[db, gate, invalidate, loadTurns, prefs.focus, progress, readTurns, recent, refreshProgress, sample, strays, today, turns],
|
|
);
|
|
|
|
/* Open the lesson if there is no transcript yet — EXACTLY ONCE.
|
|
The guard is claimed synchronously, before the first await: setting it
|
|
after one would let every concurrent run past it, which is precisely how
|
|
this managed to seed the opening turn three times over.
|
|
|
|
The opening is written by the app, not asked of the model, and it is a
|
|
seed: unstamped, never dirty, never synced. Booting used to send "Start
|
|
unit" at once, so a fresh install wrote a stamped reply and a progress
|
|
edit before anyone could configure a server — boot-time writes that
|
|
looked like the newest work in the world. Now nothing is written until
|
|
the learner presses Start. */
|
|
const opened = useRef(false);
|
|
useEffect(() => {
|
|
if (opened.current) return;
|
|
opened.current = true;
|
|
|
|
void (async () => {
|
|
if ((await loadTurns()) > 0) return; // a transcript already exists
|
|
await seedChatTurn(db, "assistant", openingTurn(unit), 0);
|
|
await loadTurns();
|
|
})();
|
|
// Mount only: `unit` is read at mount.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
/** Start the lesson over. Clears the transcript only — the roadmap, the
|
|
cards and the study log are left alone. */
|
|
const clearLesson = useCallback(async () => {
|
|
if (inFlight.current) return;
|
|
abort.current?.abort();
|
|
await editChatClear(db);
|
|
setRevealed(new Set());
|
|
setRecent([]);
|
|
setFlags({});
|
|
setStrays([]);
|
|
await seedChatTurn(db, "assistant", openingTurn(unit), 0);
|
|
await loadTurns();
|
|
}, [db, loadTurns, unit]);
|
|
|
|
/** Nothing asked yet: the opening is all there is, and Start begins it. */
|
|
const notStarted = !turns.some((t) => t.role === "user");
|
|
|
|
/* ── the word list ── */
|
|
|
|
const lastTutor = useMemo(
|
|
() => [...turns].reverse().find((t) => t.role === "assistant"),
|
|
[turns],
|
|
);
|
|
|
|
const parsedLast: ParsedMessage | null = useMemo(
|
|
() => (lastTutor ? parseMessage(lastTutor.body) : null),
|
|
[lastTutor],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!lastTutor) {
|
|
setRailWords([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
const words = await collectWords(db, lastTutor.body, parsedLast?.words ?? null);
|
|
if (!cancelled) setRailWords(words);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [db, lastTutor, parsedLast]);
|
|
|
|
const reveal = useCallback((ko: string) => setRevealed((r) => new Set(r).add(ko)), []);
|
|
|
|
/* ── the conversation ── */
|
|
|
|
/* Every keystroke in the composer re-renders the lesson, 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 the lesson. */
|
|
const streamingBody = useMemo(() => {
|
|
if (streaming === null) return "";
|
|
/* parseMessage() drops directive markup from the body, which during a
|
|
stream also covers the half-arrived kind: a directive line shows up
|
|
before the block it opens is complete, and until then parse() has no
|
|
reason to treat it as anything but prose. That is what made
|
|
"::task match" appear for one frame and vanish. */
|
|
const lines = parseMessage(streaming).body.split("\n");
|
|
/* 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]);
|
|
|
|
/* Follow the conversation — the log is the only thing that scrolls, so
|
|
setting its scrollTop moves nothing else — but only while the learner
|
|
is at the bottom: scrolling up to re-read an earlier turn is not undone
|
|
by the next token. `stick` is declared with the other refs above. */
|
|
|
|
/* Only moving UP lets go. A scroll event lands a task after the scroll
|
|
that caused it, so the follow's own scroll is often measured after the
|
|
stream has added more than STICK_PX below it — judged by distance
|
|
alone, that read as the learner scrolling away, and the log stopped
|
|
following mid-reply. Growing content never moves scrollTop up; a
|
|
finger does. */
|
|
const lastTop = useRef(0);
|
|
|
|
const onLogScroll = useCallback(() => {
|
|
const el = log.current;
|
|
if (!el || !active) return;
|
|
const top = el.scrollTop;
|
|
if (el.scrollHeight - top - el.clientHeight < STICK_PX) stick.current = true;
|
|
else if (top < lastTop.current - 1) stick.current = false;
|
|
lastTop.current = top;
|
|
}, [active]);
|
|
|
|
const follow = useCallback(() => {
|
|
const el = log.current;
|
|
if (!el || !stick.current) return;
|
|
el.scrollTop = el.scrollHeight;
|
|
lastTop.current = el.scrollTop;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!active) 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(follow);
|
|
return () => cancelAnimationFrame(id);
|
|
}, [turns, streaming, busy, active, follow]);
|
|
|
|
/* The log also changes height without changing content: the sheet
|
|
opening, the keyboard, the composer growing. Stay at the bottom. */
|
|
useEffect(() => {
|
|
const el = log.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(() => follow());
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [follow]);
|
|
|
|
/* ── rendering ── */
|
|
|
|
const isLast = (i: number) => i === turns.length - 1;
|
|
|
|
const railProps = {
|
|
words: railWords,
|
|
revealed,
|
|
onReveal: reveal,
|
|
query: railQuery,
|
|
onQuery: setRailQuery,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<RouteHead title="선생님" sub={`${unit.id} ${unit.ko}`}>
|
|
<button
|
|
className="iconbtn"
|
|
title="Roadmap"
|
|
aria-label="Roadmap"
|
|
aria-pressed={roadOpen}
|
|
onClick={() => setRoadOpen((o) => !o)}
|
|
>
|
|
<MapIcon />
|
|
</button>
|
|
<button
|
|
className="iconbtn"
|
|
title="Word list"
|
|
aria-label="Word list"
|
|
aria-pressed={!wide && detent !== "closed"}
|
|
onClick={() => {
|
|
setRailQuery("");
|
|
if (!wide) setDetent((d) => (d === "closed" ? "half" : "closed"));
|
|
}}
|
|
>
|
|
<SearchIcon />
|
|
</button>
|
|
<button
|
|
ref={menuButton}
|
|
className="iconbtn"
|
|
title="Lesson options"
|
|
aria-label="Lesson options"
|
|
aria-pressed={menuOpen}
|
|
onClick={() => setMenuOpen((o) => !o)}
|
|
>
|
|
···
|
|
</button>
|
|
</RouteHead>
|
|
|
|
<Pop
|
|
id="lesson-menu"
|
|
open={menuOpen && active}
|
|
onClose={() => setMenuOpen(false)}
|
|
anchor={menuButton.current}
|
|
label="Lesson options"
|
|
>
|
|
<div className="pop-b">
|
|
<label className="pop-n" htmlFor="lesson-focus">
|
|
선생님 focus
|
|
</label>
|
|
<select
|
|
id="lesson-focus"
|
|
value={prefs.focus}
|
|
onChange={(e) => {
|
|
const mode = e.target.value as FocusMode;
|
|
void setPref("focus", mode);
|
|
setNote(mode === "auto" ? "선생님 is back on the roadmap." : "Focus set — your next message uses it.");
|
|
}}
|
|
>
|
|
{FOCUS_LABELS.map(([mode, label]) => (
|
|
<option key={mode} value={mode}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="pop-n">
|
|
{server ? "The real 선생님" : "The offline stand-in"} · {gate.vocabulary.length} words unlocked ·{" "}
|
|
{gate.newWords.length} new this unit
|
|
</span>
|
|
</div>
|
|
<div className="pop-f">
|
|
<button
|
|
className="btn"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
void clearLesson();
|
|
}}
|
|
>
|
|
Clear lesson
|
|
</button>
|
|
<button className="btn" onClick={() => setMenuOpen(false)}>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</Pop>
|
|
|
|
<div className="lesson-wrap">
|
|
<div
|
|
className="chatcol"
|
|
onFocusCapture={(e) => {
|
|
// Typing an answer brings the sheet down to peek: what is being
|
|
// answered is never behind it.
|
|
const t = e.target as HTMLElement;
|
|
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
|
setDetent("peek");
|
|
}
|
|
}}
|
|
>
|
|
<RoadStrip
|
|
open={roadOpen}
|
|
onClose={() => setRoadOpen(false)}
|
|
busy={busy}
|
|
onUnitChange={(id, how) => {
|
|
opened.current = true;
|
|
const u = UNITS.find((x) => x.id === id);
|
|
if (!u) return;
|
|
void send(
|
|
how === "advance"
|
|
? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.`
|
|
: `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`,
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
|
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
|
const you = t.role === "user";
|
|
const body = parsed ? parsed.body : ownWords(t.body);
|
|
return (
|
|
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
|
<span className="who ko">{you ? "나" : "선생님"}</span>
|
|
{/* A turn can be nothing but blocks — some models write no
|
|
prose around an exercise at all. Rendering the bubble
|
|
anyway left an empty box above it. */}
|
|
{(body.trim() || parsed?.gloss) && (
|
|
<div className="bubble">
|
|
<MessageBody text={body} />
|
|
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
|
</div>
|
|
)}
|
|
|
|
{flags[t.id] && (
|
|
<p className="msg-flag">
|
|
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
|
|
</p>
|
|
)}
|
|
|
|
{/* 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}
|
|
words={parsed.words}
|
|
turnId={t.id}
|
|
lookups={[...revealed]}
|
|
disabled={busy || !isLast(i)}
|
|
spent={!isLast(i)}
|
|
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
|
onSkip={() => void send("Let's skip that one and just talk.")}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* 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 && (
|
|
<div className="msg" key="pending">
|
|
<span className="who ko">선생님</span>
|
|
{/* Keep the dots up while the reply so far is only block
|
|
markup: there is genuinely nothing to read yet, and an
|
|
empty bubble reads as a failure rather than as waiting. */}
|
|
{streamingBody.trim() === "" ? (
|
|
<div className="bubble dots" aria-label="선생님 is writing">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</div>
|
|
) : (
|
|
<div className="bubble">
|
|
<MessageBody text={streamingBody} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Composer
|
|
unit={unit}
|
|
busy={busy}
|
|
notStarted={notStarted}
|
|
draft={draft}
|
|
setDraft={setDraft}
|
|
keyboard={keyboard}
|
|
setKeyboard={(on) => {
|
|
setKeyboard(on);
|
|
// The two contend for the same space.
|
|
if (on) setDetent("closed");
|
|
}}
|
|
note={note}
|
|
onSend={(text) => void send(text)}
|
|
onStop={() => abort.current?.abort()}
|
|
/>
|
|
</div>
|
|
|
|
{wide && (
|
|
<aside className="railcol" aria-label="Word list">
|
|
<RailPanel {...railProps} />
|
|
</aside>
|
|
)}
|
|
</div>
|
|
|
|
{!wide && active && (
|
|
<WordSheet detent={detent} onDetent={setDetent}>
|
|
<RailPanel
|
|
{...railProps}
|
|
titleId="sheet-title"
|
|
actions={
|
|
<>
|
|
<button
|
|
className="iconbtn"
|
|
title={detent === "full" ? "Shrink" : "Expand"}
|
|
aria-label={detent === "full" ? "Shrink" : "Expand"}
|
|
onClick={() => setDetent((d) => (d === "full" ? "half" : "full"))}
|
|
>
|
|
{detent === "full" ? "▼" : "▲"}
|
|
</button>
|
|
<button
|
|
className="iconbtn"
|
|
title="Close"
|
|
aria-label="Close the word list"
|
|
onClick={() => setDetent("closed")}
|
|
>
|
|
✕
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
</WordSheet>
|
|
)}
|
|
</>
|
|
);
|
|
}
|