Two live bugs from the modal-realism stress runs:
1. Multi-select ask: picking the custom 'Other…' row cleared every
already-picked option, and picking any option cleared a typed custom
answer — single-select displacement logic applied to both modes. The
dock's selection is now a pure state machine in question-core
(toggleOption/toggleOther over DockSelection, unit-tested): single
displaces (unchanged), multi coexists — neither channel clears the
other; the typed text always survives.
2. ProbeDoneCard crashed on a REJECTED probe_done call: the model
stringified 'assessment' (the same double-encoding habit as the
options bug), zod rejected the call, the model retried — but the
card's input-preview fallback trusted the errored part's unvalidated
input shape and died on assessment.strands.map. The preview now reads
input only while the call is pending or streaming (guarded with
Array.isArray); an output-error part renders a quiet
malformed-and-retried note instead of the garbage.
Pinned: question-core.test.ts (3 dock-selection machine cases),
render-cards.test.tsx (4 probe-done card cases incl. the exact live
stringified shape, plus the multi-select ask markup pin).
Gates: tsc, lint, build, 315/315.
Key-convention policy, settled: every field accepts ⌘/Ctrl+Enter
(metaKey || ctrlKey — Mac gets ⌘↵, Windows/Linux Ctrl↵, the Gmail
convention); plain Enter stays matched to the field's species —
sends in message fields (chat composer, free-text answers — the
universal chat convention, Shift+Enter still a newline), newline in
the thinking field (composition, for dumping).
- The learner's own typed Other answer now renders INSIDE the options
container in the answered card, styled exactly like its selected
siblings (border-primary bg-accent) — it is an option, not a loose
quote floating outside the list. Quotes keep the learner's words.
- The thinking field submits with ⌘↵ on macOS, Ctrl↵ elsewhere
(metaKey || ctrlKey — the Gmail convention, same handler on every
platform). Plain Enter stays a newline: the field is for dumping.
A quiet "⌘/Ctrl + ↵ submits" hint sits next to Submit for
discoverability (text mode keeps Enter-to-submit, no hint).
The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:
- A labeled composer-TWIN field ("Your thinking") between the options
and Submit, docked with the submit button inside it exactly like the
chat composer's send. The placeholder invites dumping ("Why this
answer? What are you unsure about? Say as much as you like — the
teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
that fills on selection + inline input, styled like its siblings — so
picking Other never displaces the thinking field; typed text survives
deselection.
- The answered card promotes the thinking to a quoted first-class
block above the verdict, labeled "Your thinking" — the transcript
reads as a dialogue of thinking, not a buried "Note:" footnote.
The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
Compared the port against the original pi setup
(vault/π/learn/.pi: quiz.ts, ask-user-question.ts, teach SKILL.md) —
the pi version never produced invalid correctAnswer values. Root cause
of the port's validation failures: the pi options schema's 'Give each
option a stable value; you reference the correct one by that value in
correctAnswer' sentence was dropped in the port. Without it the model
omits values and must copy a long natural-language label verbatim into
correctAnswer — and instead slugifies it ('cultural-diagnosis' for
'A cultural diagnosis') every turn.
Now matched to pi:
- quiz schema: pi's field order (question, details, options,
multiSelect, correctAnswer, explanation) and descriptions — options
carry the stable-value instruction, correctAnswer is phrased as a
value reference ('the value field of the option you intend'), not a
copy-exact-or-fail threat. nodeId (lh-only, graph) stays.
- ask schema: dropped the redundant 'mode' param (pi has none; the dock
already infers from options presence), pi's descriptions.
- quiz tool description: pi's full text (ask contrast, note field,
dontKnow semantics) minus terminal-only details, plus lh's turn
protocol sentence.
- system-prompt: fixed the stale 'exactly two tools' line (plan/research/
demo/visual tools have their own sections).
Not matched, deliberately: tool results stay JSON (pi sends curated
text; formatAnswerMessage exists if we want it later), shuffle param
(lh always shuffles seeded), pi's ask '(Recommended)' label hint.
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):
components/** -> "@/src/core" (pure core API, browser-safe)
app/** -> "@/src/core" + "@/src/server" (server entries)
Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.
Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
(the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
/api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
transport layer; GET /api/graph's fold side-effect now lives behind
graphView() instead of inline in the route
Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.
(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
- Animation: the blob's shell morphs height (WAAPI, 180ms) on every
variant change — chat ↔ question ↔ collapsed bar — plus a quick
fade/rise (160ms) on the swapped content. Gotcha pinned in code: never
measure the shell while a morph is in flight (getBoundingClientRect
reads the ANIMATED value and corrupts the next morph's start height);
cancel running morphs before measuring, refresh settled height only
when no morph is running.
- Gradient-over-scrollbar fixed structurally: the dock is no longer an
absolute overlay on the conversation root — it's sticky bottom-0 INSIDE
the scroller (in flow). The transcript scrolls fully clear of the dock,
the fade never touches the scrollbar lane, and the pb-hack clearances
are gone.
- New question while at the bottom scrolls to reveal the dock; scrolled
up → collapsed bar, no yank.
- Thin dark native scrollbar app-wide (8px webkit + scrollbar-width:thin,
dark thumb, transparent track). Note: use-stick-to-bottom sets
scrollbar-gutter: stable both-edges on its scroller — the lane is
outside the content flow by design.
- 'Writing a question…' transcript shimmer while a tool call streams its
input (same slot as 'Thinking…'). Protocol pinned by a test asserting
tool-input-start precedes tool-input-available; scripted mock now
streams tool input as deltas like real providers do. (OpenRouter/
Anthropic often delivers the whole input in ONE delta — then the dock
appears instantly and no indicator is needed or shown.)
Verified live: grow 54→349px and shrink 349→54px fire with correct
from/to; collapse/expand intact; gradient stops before the gutter;
Thinking… observed in-page; 58/58 tests.
- A pending quiz/ask now REPLACES the chat composer: one persistent
rounded-2xl shell in the transcript overlay swaps contents (composer ↔
question ↔ collapsed bar), never remounting — the structure a future
grow-morph animation needs.
- Unified bottom row inside the question: the note field (select modes),
the answer field (text-mode asks), or the Other text — one field, one
'Submit answer' button, Enter submits.
- Collapse on scroll: uses useStickToBottomContext — scroll up and the
dock folds to a slim bar (kind chip + LaTeX-flattened question teaser +
'tap to answer'); click scrolls to bottom and re-expands. The expanded
form stays mounted-hidden so a half-made selection survives.
- Pending/streaming tool parts no longer render inline; answered parts
land in the transcript as graded cards on submit. Question generation
shows a shimmer in the shell.
- Composer back to pure chat (no mode machinery); quiz-card.tsx and the
pendingText/registerSubmit bridge deleted; render tests re-pointed at
the dock (wrapped in StickToBottom).
Verified live: docked quiz submit (note+selection), ask-text Enter
submit, collapse/expand with selection survival, bar teaser text.