Files
Hankan/app/src/ui/tutor/TutorTab.tsx
MechaCat02 467d9d1d7c fix(tutor): what live gpt-oss-20b lessons showed — marks lost, feedback swallowed, answers given away
Three unit-1.1 lessons with gpt-oss-20b through LM Studio and the server —
the first real model on the reworked turn. The letter-level check went out
right, and the +25 clamp held: a reported 80 on the first answer was stored
as 25. What failed was how the model wrote its blocks, a different way each
session. All three transcripts are in test/fixtures/, verbatim, and each
failure below is a test against them.

Marks lost. The prompt asks for `여덟 | wrong | 여덜`. The first session wrote
`we | wrong | 우라 → 우리`, English prompt first; the second wrote no ::result
at all and marked only in prose, `✗ 나 | I (humble) → 저`. evidence.ts keys on
the first field of a ::result row, so nothing was ever recorded — no
evidence, no schedule, no confusions, and a 다지기 review that could never
close. The artifact would have lost them the same way. domain/marking.ts
attaches each mark to its word only where that is unambiguous: one Korean
word first, or through a prompt of the exercise he answered, read via that
exercise's ::words as the letter check reads it. With no ::result block the
✓/✗ lines are read on the same terms, so a mark can never name a word the
exercise did not ask for; a mark on a whole sentence is still dropped. What
he mistook a word for is taken from what he actually wrote whenever the mark
itself gives no other word — the third session put the right answer there.
Its third session, marked through all of this: 20 evidence rows, 20 cards.

Progress on requests. The prompt allows marks, ::confirmed and ::progress
only in reply to an answer. The model wrote ::progress on every message, and
three requests for a new exercise took the unit from 50% to 80% with nothing
answered. A reply to anything but an answer now changes none of them.

Feedback swallowed. The model closed no blocks, so lib read what followed
each one as rows: "your score is about 5%" became a result row the student
never saw, and a "---" became a recall item he was asked to write in 한글.
Another session fenced every block in ```. gloss.ts now decides every
block's extent from the raw text — at "::", the next block, a rule or fence
line, a blank line with no row after it, or for the piped blocks the first
line without a "|" — and hands lib the blocks properly closed. The gate
audit, now run through the parser the lesson uses, still flags 7 and 2.

Answers given away. Translate rows came with their meanings ("나 | I") and
recall hints were the answers ("two | 이"). A translate row keeps only its
Korean line, and a recall hint that is the expected word, or any word the
message declares, is dropped.

Also: the spelling a recall prompt expects now keeps its qualifiers. With 나
"I, me (casual)" and 저 "I, me (humble)" in one list, "I (humble)" matched
나: no letter check was sent, and the mark for 저 was filed under 나.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:50:33 +02:00

1246 lines
46 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.
Below 840px an answer field takes the screen over — see fields.tsx. */
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import {
editAddCustomWord,
editChatClear,
editChatRemove,
editChatTurn,
editPeek,
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, isExerciseAnswer, 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 { koreanTokens, lookupMany } from "../../domain/lexicon.js";
import { wordInfo, wordToAdd, type WordInfo } from "../../domain/words.js";
import { enLookup, isLookupWord, loadEnglishIndex, type EnIndex } from "../../domain/english.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 { AnswerBar, Composer } from "./Composer.js";
import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.js";
import { LookupContext, type Lookup } from "./lookup.js";
import { EnPop, KoPop, type PopTarget } from "./WordPop.js";
import { Keyboard } from "../keyboard/Keyboard.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);
/** The word popover, and what the word it is open on is to him. */
const [pop, setPop] = useState<PopTarget | null>(null);
const [popInfo, setPopInfo] = useState<WordInfo | null>(null);
/** Every Korean word in the transcript, for its underline. */
const [words, setWords] = useState<Map<string, WordInfo>>(new Map());
const [enIndex, setEnIndex] = useState<EnIndex | null>(null);
/** Added from an English popover this session, for its ✓. */
const [addedEn, setAddedEn] = useState<Set<string>>(new Set());
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);
/** Where the log was last scrolled to; see onLogScroll. */
const lastTop = useRef(0);
const menuButton = useRef<HTMLButtonElement>(null);
/* ── the keyboard's field, and answer mode ── */
const [answering, setAnswering] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
/** The word list is up over an answer and no field has focus. */
const [parked, setParked] = useState(false);
/** The field the 한글 keyboard types into; the message box until another is touched. */
const fieldRef = useRef<KeyField | null>(null);
const messageField = useRef<KeyField | null>(null);
/** The word sheet is up over an answer: stay in answer mode, and go back to this field after. */
const keepAnswering = useRef(false);
const resumeField = useRef<KeyField | null>(null);
/** He switched the keyboard himself this exercise: the exercise no longer picks it. */
const kbManual = useRef(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
/** The open exercise's submit, for the answer bar's 제출. */
const submitTask = useRef<(() => void) | null>(null);
/** Re-render the answer bar's count as answers are typed. */
const [, recount] = useReducer((n: number) => n + 1, 0);
const coarse = useMedia("(pointer: coarse)");
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 = [],
restore = false,
}: {
lookups?: string[];
/** Typed into the box: if nothing comes back, put it back there. */
restore?: boolean;
} = {},
) => {
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
setNote("선생님 is reading your answer…");
// Out of answer mode, and the keyboard goes back to the message box:
// the exercise is done with.
clearTimeout(exitTimer.current);
keepAnswering.current = false;
resumeField.current = null;
setAnswering(false);
setParked(false);
fieldRef.current = null;
setActiveId(null);
// What he looked up belongs to THIS answer; a typed message has none.
lastLookups.current = lookups;
// The exercise he is answering, for reading the marks that come back.
const answeredTurn = [...turns].reverse().find((t) => t.role === "assistant");
const answered = answeredTurn ? parseMessage(answeredTurn.body) : null;
// Answering scrolled the log up to the fields; sending means he wants
// the reply, so follow it again.
stick.current = true;
const turnId = await editChatTurn(db, "user", body);
await loadTurns();
/* Nothing came back. Take the message out again, so the thread does
not collect his turns with no replies between them, and put a typed
one back in the box. An exercise's answers are still in its fields. */
const withdraw = async () => {
await editChatRemove(db, turnId);
setTurns(await readTurns());
if (restore) setDraft((d) => d || body);
};
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));
},
});
// Counted once the tutor has it. Counted before the request, as the
// artifact does, a message that failed and was sent again counted twice.
await noteAnswer(db, progress, body);
/* 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,
answered,
answer: body,
afterAnswer: isExerciseAnswer(body),
});
// 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));
kbManual.current = false;
}
setRecent(applied.recent);
await refreshProgress();
invalidate();
} catch (err) {
const e = err as SampleError;
setStreaming(null);
if (e?.code === "cancelled") {
if (e.text) {
// Stopped part-way: what arrived is kept, and so is the answer.
await noteAnswer(db, progress, body);
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
setTurns(await readTurns());
} else {
await withdraw();
}
setNote(null);
} else if (e?.text) {
// Cut off part-way by an error: what arrived is kept.
await noteAnswer(db, progress, body);
await editChatTurn(db, "assistant", e.text);
setTurns(await readTurns());
setNote(`${e.message ?? "The reply was cut off"} — send again to finish it.`);
} else {
await withdraw();
const why = e?.message ?? "The tutor could not be reached";
setNote(
`${why.replace(/[.!]$/, "")}${restore ? "your message is back in the box" : "your answers are still there"}. Try again.`,
);
}
} 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],
);
/* The first tier: what every Korean word on the screen is to him. Read
again when the transcript changes, and whenever cards may have. */
useEffect(() => {
let cancelled = false;
const tokens = koreanTokens(turns.map((t) => t.body).join("\n"));
const declared = parsedTurns.flatMap(({ parsed }) => parsed?.words ?? []);
void wordInfo(db, tokens, declared).then((m) => {
if (!cancelled) setWords(m);
});
return () => {
cancelled = true;
};
}, [db, parsedTurns, revision, turns]);
useEffect(() => {
let cancelled = false;
void loadEnglishIndex(db).then((index) => {
if (!cancelled) setEnIndex(index);
});
return () => {
cancelled = true;
};
}, [db, revision]);
/* The second tier. Any lookup is a lookup — for the answer's "I had to
look up" and for the peek tally, which counts the Korean he looked at:
the artifact counted an English lookup under the English word. */
const lookedUp = useCallback(
(kos: string[]) => {
setRevealed((r) => new Set([...r, ...kos]));
for (const ko of kos) void editPeek(db, ko);
},
[db],
);
const openKo = useCallback(
(anchor: HTMLElement, token: string) => {
setPop({ kind: "ko", token, anchor });
lookedUp([token]);
},
[lookedUp],
);
const openEn = useCallback(
(anchor: HTMLElement, word: string, list: string[], at: number) => {
const hits = enIndex ? enLookup(enIndex, list, at) : [];
if (!hits.length) return;
setPop({ kind: "en", word, hits, anchor });
lookedUp(hits.map((h) => h.ko));
},
[enIndex, lookedUp],
);
const lookup = useMemo<Lookup>(
() => ({
stateOf: (token) => words.get(token)?.state,
hasEnglish: (list, at) =>
enIndex !== null && isLookupWord(list[at] ?? "") && enLookup(enIndex, list, at).length > 0,
openKo,
openEn,
}),
[enIndex, openEn, openKo, words],
);
useEffect(() => {
if (pop?.kind !== "ko") {
setPopInfo(null);
return;
}
const known = words.get(pop.token);
if (known) {
setPopInfo(known);
return;
}
// A chip or a word the transcript's scan did not reach.
let cancelled = false;
setPopInfo(null);
void wordInfo(db, [pop.token]).then((m) => {
if (!cancelled) setPopInfo(m.get(pop.token) ?? null);
});
return () => {
cancelled = true;
};
}, [db, pop, words]);
// The word it is open on stays highlighted while it is.
useEffect(() => {
const el = pop?.anchor;
if (!el) return;
el.dataset.open = "1";
return () => {
delete el.dataset.open;
};
}, [pop]);
const addWord = useCallback(
async (word: { headword: string; pos: string; gloss: string }) => {
await editAddCustomWord(db, word);
invalidate();
},
[db, invalidate],
);
/* 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 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]);
/* ── answer mode ── */
/** Bring an answer's row to the top of the log, where the keyboard cannot cover it. */
const showRow = useCallback((input: HTMLElement | null) => {
const el = log.current;
const row = input?.closest(".ti-row");
if (!el || !(row instanceof HTMLElement)) return;
stick.current = false;
el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top - 10;
lastTop.current = el.scrollTop;
}, []);
const exitAnswering = useCallback(() => {
clearTimeout(exitTimer.current);
keepAnswering.current = false;
resumeField.current = null;
setAnswering(false);
setParked(false);
// The earlier messages come back; land at the exercise, the end of the log.
stick.current = true;
}, []);
const onFieldFocus = useCallback(
(field: KeyField) => {
clearTimeout(exitTimer.current);
fieldRef.current = field;
setActiveId(field.id);
if (field.kind !== "answer" || wide) return;
keepAnswering.current = false;
setParked(false);
setAnswering(true);
// The exercise picks the keyboard — 한글 for writing, none for
// translating, so one is not left up from the last — unless he has
// chosen for himself this exercise.
if (!kbManual.current) setKeyboard(field.script === "ko");
requestAnimationFrame(() => showRow(field.el()));
},
[showRow, wide],
);
const onFieldBlur = useCallback(
(field: KeyField) => {
if (field.kind !== "answer") return;
clearTimeout(exitTimer.current);
exitTimer.current = setTimeout(() => {
if (keepAnswering.current) return;
// A word looked up while answering is not leaving the answer.
if (document.querySelector(".pop")) return;
const a = document.activeElement;
if (a instanceof HTMLElement && a.matches("input[data-field]")) return;
exitAnswering();
}, 160);
},
[exitAnswering],
);
const fields = useMemo<Fields>(
() => ({ keyboard, activeId, answering, onFocus: onFieldFocus, onBlur: onFieldBlur }),
[keyboard, activeId, answering, onFieldFocus, onFieldBlur],
);
const toggleKeyboard = (manual: boolean) => {
if (manual) kbManual.current = true;
const on = !keyboard;
setKeyboard(on);
// The keyboard and the word sheet contend for the same space.
if (on) setDetent("closed");
if (on && !document.activeElement?.matches("input[data-field], .chat-in textarea")) {
(fieldRef.current ?? messageField.current)?.el()?.focus({ preventScroll: true });
}
};
/* inputmode is read when a field gains focus. Switching keyboards on a
focused field changes nothing until it is focused again. */
useEffect(() => {
if (!coarse) return;
const el = document.activeElement;
if (!(el instanceof HTMLElement) || !el.matches("input[data-field], .chat-in textarea")) return;
el.blur();
el.focus({ preventScroll: true });
}, [keyboard, coarse]);
/* 가 — the word list over an answer. The sheet and a keyboard contend for
the same space, so the field lets go while the list is up; answer mode
stays, and closing the list goes back to the field. */
const toggleWords = () => {
if (detent !== "closed") {
setDetent("closed");
return;
}
const field = fieldRef.current?.kind === "answer" ? fieldRef.current : null;
resumeField.current = field;
keepAnswering.current = true;
setParked(true);
field?.el()?.blur();
setDetent("half");
};
/** The popover's "Word list": the column is already there on a wide screen. */
const openList = () => {
if (wide) return;
if (answering) {
if (detent === "closed") toggleWords();
return;
}
setDetent((d) => (d === "closed" || d === "peek" ? "half" : d));
};
useEffect(() => {
if (detent !== "closed" || !keepAnswering.current) return;
const field = resumeField.current;
keepAnswering.current = false;
resumeField.current = null;
setParked(false);
if (field) setTimeout(() => field.el()?.focus({ preventScroll: true }), 30);
else exitAnswering();
}, [detent, exitAnswering]);
useEffect(() => {
if (answering && (wide || !active)) exitAnswering();
}, [active, answering, exitAnswering, wide]);
/* The shell hides the nav for it, so the flag goes where the shell can see it. */
useEffect(() => {
const flags = document.body.dataset;
if (answering) flags.answering = "1";
else delete flags.answering;
return () => {
delete flags.answering;
};
}, [answering]);
const step = (d: number) => {
const all = liveAnswerFields(log.current);
const to = all[all.findIndex((el) => el.dataset.field === activeId) + d];
if (!to) return;
to.focus({ preventScroll: true });
showRow(to);
};
const answerFields = answering ? liveAnswerFields(log.current) : [];
const at = answerFields.findIndex((el) => el.dataset.field === activeId);
const onField = at >= 0 && !parked;
const position = !answerFields.length
? ""
: onField
? `${at + 1} / ${answerFields.length}`
: `${answerFields.filter((el) => el.value.trim()).length} of ${answerFields.length}`;
const target = fieldRef.current ?? messageField.current;
/* ── rendering ── */
const isLast = (i: number) => i === turns.length - 1;
const railProps = {
words: railWords,
revealed,
onReveal: reveal,
query: railQuery,
onQuery: setRailQuery,
infoOf: (ko: string) => words.get(ko),
onAdd: addWord,
};
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>
<Pop
id="word"
open={pop !== null && active}
onClose={() => setPop(null)}
anchor={pop?.anchor ?? null}
label={pop?.kind === "en" ? "English to Korean" : "Word"}
>
{pop?.kind === "ko" && (
<KoPop
token={pop.token}
info={popInfo}
stray={strays.includes(pop.token)}
onAdd={() => {
if (popInfo) void addWord(wordToAdd(popInfo));
setPop(null);
}}
onAsk={() => {
setPop(null);
void send(
`You used ${pop.token} in that exercise, but you never taught it and it is not in my word list. What does it mean — and was it meant to be there at all?`,
);
}}
onFind={() => {
setRailQuery(pop.token);
setPop(null);
openList();
}}
onList={() => {
setRailQuery("");
setPop(null);
openList();
}}
/>
)}
{pop?.kind === "en" && (
<EnPop
word={pop.word}
hits={pop.hits}
added={addedEn}
onAdd={(e) => {
setAddedEn((a) => new Set(a).add(e.ko));
void addWord({ headword: e.ko, pos: e.pos, gloss: e.gloss });
}}
/>
)}
</Pop>
<LookupContext.Provider value={lookup}>
<FieldsContext.Provider value={fields}>
<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 (keepAnswering.current) return;
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
setDetent("peek");
}
}}
onInputCapture={() => {
if (answering) recount();
}}
>
<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] })}
submitRef={isLast(i) ? submitTask : undefined}
/>
)}
</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}
onToggleKeyboard={() => toggleKeyboard(false)}
messageField={messageField}
note={note}
onSend={(text) => void send(text, { restore: true })}
onStop={() => abort.current?.abort()}
>
{answering && (
<AnswerBar
position={position}
keyboard={keyboard}
wordsOpen={detent !== "closed"}
canPrev={onField && at > 0}
canNext={onField && at < answerFields.length - 1}
busy={busy}
onDone={() => {
(document.activeElement as HTMLElement | null)?.blur();
exitAnswering();
}}
onWords={toggleWords}
onKeyboard={() => toggleKeyboard(true)}
onPrev={() => step(-1)}
onNext={() => step(1)}
onSubmit={() => {
exitAnswering();
if (!kbManual.current) setKeyboard(false);
submitTask.current?.();
}}
/>
)}
{keyboard && target && !parked && (
<Keyboard
composer={target.composer}
onChange={target.apply}
target={target.kind === "message" ? "message" : "answer"}
onKey={() => {
const el = target.el();
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
}}
onDismiss={() => setKeyboard(false)}
/>
)}
</Composer>
</div>
{wide && (
<aside className="railcol" aria-label="Word list">
<RailPanel {...railProps} />
</aside>
)}
</div>
</FieldsContext.Provider>
</LookupContext.Provider>
{!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>
)}
</>
);
}