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>
1246 lines
46 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|