The top-right overlay doubles as the state legend and the filter
surface: a search box (matches name or statement, case-insensitive)
plus one row per node state in reading order (taught, known, missed,
don't know, candidate) with its color dot, label, and total count.
Clicking a row toggles that state's visibility.
Filtering flows through sigma's official nodeReducer/edgeReducer
settings: hidden nodes drop out of rendering, hover, picking, AND
labels; edges drop when either endpoint is hidden; an open detail card
closes if its node is filtered out. Pure predicate (isNodeVisible) is
exported and covered by 7 tests; the reducers just consume its
verdict. Verified live: search 'Nietzsche' 5457->1687->5457 label px
(exact restore), known toggle drops 13 discs+labels and restores,
nonsense query shows the 'No matching nodes.' hint with zero labels,
counts 0/13/0/0/10 match the ledger.
chat.tsx woven by hand: UI's session-surface layout (transcript column +
right sidebar as full-height siblings, h-10 header with PhaseChip) wrapped
in sub-agents' JobsContext.Provider; the message map carries both the
steer ternary (steers bypass the Message wrapper — the model-facing glue
text never renders) and UI's PhaseDivider emission (steers are verified
phase-boundary no-ops). Right sidebar = sub-agents' fleet panel column
(SubAgentsPanel above TreePanel, xl-only) carried onto UI's skeleton.
contracts.ts auto-merged (ChatSummary.phase/pendingQuestion + ChatListItem,
additive above the async-protocol section).
Smoke-verified on the merged tree: steer renders bare as its artifact
badge with no learner bubble, no rendered glue text, and no spurious
phase divider at the steer boundary.
composeInstructions loaded EVERY .md in src/agent/prompts — including
README.md, the directory's human documentation, which rode into the
system prompt as its own section (visible in OpenRouter logs, old POC
paths and all).
The loader now enforces the documented convention: only numeric-prefix
fragments (\d+-*.md) compose. README.md stays in the directory as docs
and can never reach the model again. README's rule text updated to
match; composeInstructions exported for tests; regression test asserts
the README never leaks and every numeric fragment composes verbatim.
Figures now always fill the card's width (h-auto keeps the aspect
ratio): narrower svgs scale up instead of leaving a white strip on the
right when they carry their own background color; oversized ones still
shrink to fit. bg-white stays as a neutral backing for svgs without
their own background.
Three refinements from live testing:
- Steer messages no longer sit inside the user Message container (the
bubble background + right alignment it gave artifact cards). chat.tsx
renders SubagentSteerCard directly in the transcript column — full
width, centered, same register as the teacher's artifacts.
- Research steers render a quiet "Research complete · N sources" badge
(the dispatch-settled idiom), not the full brief: briefs are long, the
wake turn weaves them into the lesson anyway, and the full text stays
persisted in the artifact part — a "view full research" affordance
can be added later without any protocol change. Failures keep the
failure card (the brief explains what went wrong).
- The sub-agents' verification notes (visual/demo builder notes) are
model-facing glue and no longer render on success; failure notes
remain the error explanation.
SSR-verified against a synthetic record: badge + SVG bare in the
transcript (no is-user wrapper), notes/brief confined to the RSC
payload. 229/229, typecheck, lint, build green.
The pi-style ack protocol (previous commit) left the client describing the
old world: artifact cards read the ack string as a result ("could not be
displayed"), the only job poller rendered on input-available parts that no
longer exist (so wake turns landed invisibly until re-navigation), steers
rendered as learner bubbles with no SVG anywhere, and server-persisted
assistant messages carried empty SDK ids (duplicate React keys).
- persistTurn (turn-service): fill empty message ids at the canonical write
— server-only messages (last turn's, wake turns') now key uniquely;
client-authored ids untouched.
- chat.tsx: ONE central /api/jobs poll (fast while jobs run, slow discovery
cadence, off for job-less sessions) drives every job surface, re-syncs
once when a job settles (the record already holds the steer + wake
response at flip time), and adopts server-side wake turns via the same
turnInFlight machinery as returning mid-turn. Steers branch to the
artifact card — never a bubble. Composer no longer gates on jobs.
- /api/jobs now reports turnInFlight alongside the job list.
- dispatch-card: the tool-call box — live activity by toolCallId while
running, quiet "delivered"/"failed" one-liner after; the ack is visible
observability, the artifact lives in the steer.
- steer-card: renders the delivered artifact by kind (research brief /
sanitized SVG / sandboxed demo iframe) from the data-subagent-artifact
part; result renderers extracted and exported from the artifact cards.
- sub-agents-panel: right-sidebar section above the plan tree listing
running sub-agents (kind, elapsed, live activity) — vanishes when idle.
- tests: dispatch/steer/panel render tests re-target the new protocol;
persistTurn id-hygiene suite. 229/229 green, build green.
Out-of-lane touches for master review: chat.tsx + components/** (cleared
this wave), app/api/jobs/route.ts, src/server/turn-service.ts, contracts
protocol doc §2. Live-verified on :3003 (full loop, SSR DOM checked).
Redesign per review feedback: the colored phase strips were too busy —
removed. One small dot now carries two channels:
- COLOR = phase (sky/violet/emerald, same system as chip + dividers)
- FILL + MOTION = what's owed:
- pulsing = teacher is working (the only state that deserves motion)
- solid = a question awaits the learner's answer — the dominant
resting state of real sessions, so deliberately calm
- hollow = idle, nothing owed
The amber attention channel is gone entirely (it would read as a
phantom fourth phase). The meta line now leads with the useful fact:
"Waiting for your answer · 5m ago" / "Teacher is working…" — the
message count only shows when idle. Ordering stays pure recency.
The job tools (research/demo/visual) switch from client-side turn-breaking
calls to pi's model: execute resolves IMMEDIATELY with a fire-and-forget
ack, the teacher's turn continues, and the result arrives later as an
injected user-role steer message that wakes the teacher in a new
server-side turn.
- contracts.ts: THE ASYNC PROTOCOL rewritten (ack now, steer later); steer
types (SubagentSteerMessage, metadata.subagentResult + data-subagent-
artifact part), pure buildSubagentSteer/steerDelivered/isSubagentFailure
- tools/jobs.ts: jobTools(ctx) factory — server-side execute creates the
job (deduped by toolCallId), fires spawn with the dispatch-time input
(the part isn't persisted yet at execute — a transcript scan would race)
- runner.ts: runJob(jobId, input?) builds the steer and hands it to the
injected deliver seam; pipeline owns the done/error flip (a polling
client re-syncs once, at the end); failures still steer
- server/jobs.ts (new pipeline): per-chat serialized queues on globalThis,
idle gates (turn liveness + pending question, 1.5s grace), 409 retry
with backoff, idempotent per jobId, stale-job recovery sweep (re-deliver
or re-spawn); wake turns run through the same handleChatRequest
composition via setTurnRunner injection
- chat-handler.ts: wires runner+deliver+tools ctx; drops spawnTurnJobs
from onFinish; teacher default registry = question tools only
- plan.ts: PlanToolContext split out (plan tools need no job wiring)
- visual-maker: edit_svg added (pi parity — exact-match single
replacement, fuzzy context on a miss); researcher: pi's kept/dropped
source accounting restored
- prompts 20-jobs/40-demos: async discipline rewritten (announce and keep
teaching, no waiting/polling/fabricating, parallel dispatch, teach with
the artifact when it arrives)
- tests: jobs.test.ts rewritten (dispatch ack mid-turn, runner steers,
wake pipeline: deferral/409/idempotency/failure), demos.test.tsx runner
tests ported to runJob + fake deliver; 222 green
FLAGGED surgical touches outside the stream's owned files, for master
review at merge: src/core/contracts.ts (protocol doc + steer types),
src/core/ui-message.ts (job tool + data-part types), src/server/
chat-handler.ts (wiring), src/server/turn-service.ts (spawnTurnJobs
retirement), src/agent/teacher-agent.ts (default tools), src/agent/tools/
plan.ts (context type split).
Verified live on PORT=3003 with real models: teacher dispatched research
mid-turn, researcher ran real searches/fetches, steer injected on quiz
answer, wake turn wove the brief in citing its sources, job flipped done;
a client/wake race correctly 409'd.
- Chat page: the tree panel is now a full-height sibling of the whole
transcript column (header + scroller), exactly like the left sidebar —
the session header (name + phase chip) spans from the left sidebar's
edge to the panel's edge instead of riding over the panel.
- Plan graph: the zoom controls sit inside the pan surface, so their
pointerdown started a capture drag and the click never reached the
button (felt dead). startDrag now ignores presses on buttons.
- Fully zoomed out IS the fit view: zooming to the floor snaps to the
centered fit (pure zoomedGraphView), pan is locked there (no drag
begins, cursor-default instead of grab), and sitting at the floor
re-arms auto-fit so later tree edits keep the graph fitting.
- Fit padding 48px → 16px: the default view fits more graph.
The nested list stays as the default/collapsed view; the new expand
toggle (list header) opens the graph — a hand-built SVG diagram
(mermaid STYLE, no mermaid renderer: full control of the styling so it
speaks this app's design language):
- Top-down layered layout (topoLayout depth = rows, authoring order
within a row, rows centered on the widest) — vertical space is the
cheap axis; boxes + vertical bezier arrows with arrowheads.
- Status coloring matches the list marks: done nodes are colored in
(teach-phase green tint — the graph fills in as the session
progresses), current = the highlighted position, next = unlocked
ring, upcoming = quiet.
- Drag pan + wheel zoom (to the cursor, non-passive), +/- and
fit-to-panel buttons. The zoom FLOOR is fit: fully zoomed out, the
whole diagram always fits the panel — it can never disappear into
nothing. Auto-fits on measure/layout change until the learner takes
manual control; fit re-enables it.
- Panel widens to w-96 in graph mode; mode persists
(lh:tree-panel-mode: rail|list|graph, legacy key migrated).
- wrapTitle + graphLayout pure, unit-tested; TreeGraph SSR-render
tested (statuses, arrows, wrapping, data-next).
- components/phase-divider.tsx: PhaseChip (header, next to the session
title: phase-colored dot + Probing/Planning/Teaching) and PhaseDivider
(the transcript boundary: color-coded dotted rules + a small chip —
'Plan · N nodes' at the plan seam, 'Teaching' at the teach seam; probe
never renders, phases only advance). planNodeCount reads the seam
message's first plan tree.
- chat.tsx: dividers render at MESSAGE boundaries (phaseTimeline, one
linear fold) — the seam is the transition to the message's phase, so
the seam message's prose sits below its divider. requiresReveal now
uses phaseAt3 teach (an occupied node), so probe- AND plan-phase
questions auto-open.
- Retired the part-level plan-divider (artifact registry): the boundary
is message-level, not part-level. Its tests replaced by
render-phase-divider tests; core/phases gains tested phaseTimeline.
- SessionRow (extracted, exported for tests): leftmost phase strip
(bookmark tick, per-phase color: probe/plan/teach) + status dot left of
the title — pulsing amber attention dot when an answer is owed, quiet
breathing dot while the teacher works, nothing when it's the learner's
turn. A pending answer outranks 'working'.
- globals.css: phase + attention color tokens (one system shared with the
upcoming header chip and dividers) and the two dot animations, both
disabled under prefers-reduced-motion.
- SessionsSidebar polls /api/chats every 2.5s while open (payload is tiny
now that busy/phase/pendingQuestion come from the list) and pauses while
the tab is hidden; fetch-on-navigation preserved.
- core/phases.ts: Phase3 (probe/plan/teach) + pure derivations —
phaseOfTree (point-in-time, from the stored plan tree: no nodes → probe,
no occupied node → plan, occupied → teach) and phaseAt3 (monotonic
transcript fold for the dividers: failed plan_init never marks a seam;
phase only advances).
- contracts.ts: ChatSummary gains record-derived phase + pendingQuestion;
ChatListItem adds liveness-merged busy.
- store/chats.ts listChats computes the record-derived fields (tree +
pending question scan).
- api/chats route merges busy = turn running || running background job.
Lane crossing granted by master: contracts/store/api (schema arbitration).
Data-only inference: pre-plan planning reads as probe until the plan lands.
Schema: GraphNode + ExtractedProposition carry 'name' — a short 2-6 word
header, the rendering handle. Node theory made explicit: a node is an
objective world-facing fact; the learner's relationship to it lives
entirely in the event ledger (state labels are derived, never stored).
Extractor + auditor instructions enforce it — misconceptions state the
wrong idea itself as a claim, never 'the learner believes Y'. Legacy
ledger lines without a name derive one from the statement on read.
graph-view: labels use the name; the detail panel shows name as header
and statement as body.
The bug: answering a quiz and navigating away mid-turn, then returning,
re-prompted the same question. Root causes: (1) the answer was durable only
at onFinish — every mid-turn view read the stale pre-answer record;
(2) the server had no notion of a turn in flight; (3) remount treated the
stale record as current, with no re-attach path. Worse, the stale dock let
the quiz be answered again, racing two turns into a last-writer-wins
saveMessages clobber.
Fix (four pieces):
- Write-ahead persist in chat-handler: the request body IS the client's
canonical state — saved to disk BEFORE the stream starts. The answer is
durable the instant the request arrives; crashes recover via the existing
mount continuation.
- Turn liveness registry (src/server/turn-liveness.ts): in-memory chatId →
in-flight map with a TTL leak guard; beginTurn rejects concurrent turns
(409) so one turn ever owns a chat. endTurn runs in onFinish AFTER
persistTurn, so pollers that see not-in-flight always read a finished
record; a turn-start failure clears the registry (no lockout).
- GET /api/chats/[id] reports turnInFlight; the chat page passes
initiallyInFlight to the client.
- Client re-attach (components/chat.tsx): while in flight, busy shimmer +
composer gated + mount continuation suppressed; poll /api/chats/[id] until
the turn finishes, then syncFromServer adopts the completed transcript.
Stop stays local-stream only (a server turn has no client request to
abort).
Verified by scripted-model tests: concurrent turn 409 + registry lifecycle,
write-ahead durability mid-turn, failed-turn lockout guard.
quizInputSchema now enforces (via pure quizInputProblems + superRefine) that
correctAnswer resolves against option values, options are non-empty and
unique, and multi-answer requires multiSelect — schema-valid now implies
gradeable. The AI SDK already returns invalid tool input to the model as a
tool-error part (verified in ai@7 run loop), so the teacher retries in the
same turn. chat-handler onError passes tool-input validation messages
through to the client (masked 'An error occurred.' for everything else).
Covers the Gate 3 GLM failure: correctAnswer slug matching no option.
The knowledge graph's write channel is now the pivot's target
architecture (vision §1 construction):
- src/agent/subagents/extraction.ts — the pipeline factory
(createExtractionPipeline({ chatStore, graphStore, extractor?,
auditor? }); models env LH_EXTRACT_MODEL ?? LH_MODEL):
* transcriptView(): the compact view both LLM stages see — final plan
tree with node ids, conversation verbatim, QUIZ lines with outcomes,
mark_done lines; job tool parts are one-liners, plan ops fold into
the tree line.
* Extractor: transcript → SessionFindings (propositions incl.
misconceptions and prose-only supports edges, attribution for
UNTAGGED probes, all with verbatim evidence) via one zod-validated
submit_findings call.
* Auditor: transcript + findings → the verified subset + drops (the
faithfulness net) via submit_review.
- src/core/graph.ts — emitChat/foldStaleChats/replayFinalTree retire;
mergeFindings() replaces them as the pipeline's deterministic apply
stage: exact/alias identity resolution (one mapping line per (chat,
graph node)), edge dedupe, and event rebuild FROM THE TRANSCRIPT's tool
parts — kinds/timestamps/refs/notes deterministic, the LLM's only event
say being WHICH proposition an untagged probe tested (retroactive —
the vision's open question #3, resolved structurally: tagged events
resolve deterministically; attribution rescues untagged and
tagged-but-unmapped ones; unattributable land null + question text).
pendingQuizAnswers() is the trigger helper.
- Trigger (turn-service.ts triggerExtraction, replacing the old
foldTurnIntoGraph body): once a chat accumulates
EXTRACTION.everyNQuizAnswers (LH_EXTRACT_N, default 3) new answered
quizzes, fire the pipeline async — single-flight per chat, failure
checkpoints nothing and re-runs on the next trigger. Deliberately a v1
placeholder isolated in ONE seam, trigger-agnostic pipeline.
- GET /api/graph is now a pure read (foldStaleChats gone).
- NodeMapping.treeNodeId → sourceId (the mapping key is now the writer's
local proposition id — tree node when tree-derived); store strict-read
schema updated. Local ledger data regenerates through the pipeline.
- Tests: graph.test.ts rewritten around mergeFindings (identity,
attribution paths incl. ghost-rescue, idempotency, cross-session
provenance, trigger counting), graph-context.test.ts merge section
converted to findings-driven, NEW extraction.test.ts — transcriptView
units + the full pipeline with mock models (audit drops, failure
checkpoints nothing, single-flight). 195/195, tsc/eslint/build clean.
Probe attribution (vision open question): probes attach retroactively by
the extractor against the whole transcript; a pre-tree probe maps to a
proposition the tree only articulates later. Fuzzy/embedding merge
adjudication stays deferred (conservative exact/alias only).
The chat turn is now composed, not implemented, by chat-handler.ts:
- src/server/turn-service.ts: one named, error-contained seam per turn
responsibility — buildTeacherContext (W8 read channel), persistTurn
(canonical state write), spawnTurnJobs (async protocol kick-off),
foldTurnIntoGraph (session→graph consolidation; the extraction-pipeline
stage swaps this body behind the same seam). Each takes its deps
explicitly; failures log one [chat <id>] line and never touch the
response.
- chat-handler.ts wires: storage ports (stores), the job runner, the
teacher tools (ctx = chatId + injected ChatStore), and the services.
It no longer contains any logic beyond request validation.
Agent layer now receives dependencies instead of importing them — a new
lint zone enforces it (src/agent never imports src/server or src/store;
verified to fire both ways):
- runner.ts: createJobRunner({ chatStore, jobStore }) factory returning
the spawn hook + the three job runners; the model-override test seam
stays module-level. Bodies otherwise verbatim.
- plan tools: persist through the ctx-injected ChatStore
(TeacherToolContext = { chatId, chatStore }).
- teacher-agent's dependency-free default registry is now the true
client-side set: questionTools + jobTools (jobTools was ctx-parameter-
shaped but never used its ctx — now honestly context-free). Production
always passes createTeacherTools({ chatId, chatStore }); tests that
drive a bare mock model get the client-side set unchanged.
Zero behavior change: tsc clean, 190/190 tests, eslint clean, production
build clean. Tests inject the same fs ports the chat-handler does.
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.
The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
pure — it takes the storage ports as explicit parameters (GraphDeps).
No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
(chatStore/graphStore/jobStore); bodies otherwise verbatim.
NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
(they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
and chat-handler wire from it; agent (plan tools, sub-agent runner)
call the port objects. The shell surface (@/src/server exports) is
unchanged.
Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
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.)
- src/core/math-delims.ts: pure preprocessing for the \(...\)/\[...\] math
contract — balanced delimiters rewrite to the two-dollar forms remark-math
parses ($ is never math). Skips code fences/spans, escaped delimiters, and
leaves unbalanced (mid-stream) delimiters untouched. Display math renders
as a block: mid-paragraph gets blank-line padding; indented (list items)
degrades to inline so list continuations don't half-parse.
- message.tsx: transform in the render path (children-memoized); math plugin
now singleDollarTextMath: false — the currency hazard dies structurally.
remark-breaks via the cjk remarkPluginsAfter slot (the bare remarkPlugins
prop would replace the [remark-gfm, codeMeta] defaults).
- mermaid: dark theme + sans font via Streamdown's mermaid prop; dark-styled
error fallback (components/mermaid-error.tsx) with error, source in
<details>, and retry.
- globals.css (appended): @source for streamdown dist — its internal UI
chrome (code blocks, mermaid containers, tables) was entirely unstyled
because Tailwind v4 never scans node_modules; --color-sidebar → card; wide
mermaid SVGs capped to the column; .katex-display scrolls.
- tests/render-markdown.test.tsx: 26 tests (transform unit + render). The
math describe block in render-cards.test.tsx is updated to the new
contract ($...$ now renders as literal text — pinned).
- src/store/jobs.ts: per-job JSON store in data/jobs (create/get/update/list,
dedup by toolCallId)
- src/agent/tools/jobs.ts: research tool — client-side (no execute), turn ends
at the call; input per contracts ResearchToolInput
- src/agent/tools/web.ts: server-side web_search (Google CSE) + web_fetch
(Readability/Turndown/linkedom, Jina fallback) — errors return as strings,
never thrown into the agent loop. Deps: @mozilla/readability, linkedom,
turndown (+@types/turndown dev)
- src/agent/subagents/researcher.ts: isolated ToolLoopAgent (web tools only,
12-step cap), researcher prompt ported from pi
- src/agent/subagents/runner.ts: spawnPendingJobs hook + runResearchJob —
fire-and-forget, activity line per tool call, fulfills the part in the
LATEST chat record (output-available) before flipping job status; failure
still fulfills the part so the teacher learns the outcome
- src/server/chat-handler.ts: onFinish spawn hook after saveMessages
- app/api/jobs/route.ts: GET ?chatId= polling endpoint
- components/artifacts/job-card.tsx + registry entry: polling activity card
(2s) → syncFromServer on finish; research-brief card (markdown + sources)
- src/agent/prompts/20-jobs.md: research doctrine for the teacher
- tests/jobs.test.ts: store CRUD, spawn hook via scripted mock teacher,
runner completion/failure with fake model + stubbed fetch
- src/core/plan-tree.ts: pure tree ops (createTree/addNode/updateNode/removeNode/
addEdge/removeEdge/setCurrent/markDone), DAG invariants (unique ids, existing
endpoints, no self-loops/duplicates/cycles), cascade remove, version bumps on
state changes only (true no-ops for idempotent setCurrent/markDone).
- src/agent/tools/plan.ts: plan_init (refuses re-init), plan_add_node (optional
parents → requires edges), plan_update_node, plan_remove_node, plan_add_edge,
plan_remove_edge, plan_set_current, plan_mark_done. Execute = getChat → op →
saveTree → PlanToolResult with the full updated tree; PlanTreeError and missing
chats become {ok:false, error, tree}.
- src/agent/prompts/10-plan-tree.md: tree-authoring doctrine (when: post-probe;
grain: one reasoning step per node; discipline: set_current on start,
mark_done on demonstrated mastery; freedom: mid-session restructure).
- tests/plan-tree.test.ts: every validation error path, version semantics,
cascade remove, no-mutation, tool round-trips against the real chat store.
- 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.
Sidebar (replaces the modal drawer):
- Inline, toggleable, no backdrop/focus theft — can stay open while working
- Open state persists across pages (provider in root layout) and reloads
(localStorage; starts closed server-side to avoid hydration mismatch,
stored value applied post-mount; default open on first run)
- Hamburger in page headers only when closed; close button in sidebar header
Floating composer blob (per reference screenshot):
- Floats over the transcript (absolute overlay), persists at bottom while
scrolling, content fades behind it (gradient mask); pb-44 scroll clearance
- Concentric corner radii: blob rounded-2xl(16px) − p-1.5(6px) = send
rounded-[10px] = 25% of the 40px button; CornerDownLeft icon per reference
- Stop moved from header into the blob (busy → square icon)
- prompt-input.tsx + 7 orphan ui files pruned (attachments/command palette
unused); hand-rolled Composer is ~100 lines and render-tested
Pending-question morph (one text field on screen, never two):
- Quiz/ask-select pending → blob becomes the note field (send hidden, hint
line above); card's internal note input removed; Submit consumes it
- Ask free-text pending → blob becomes the answer field; its send submits
via the card's registered submit api (ref bridge); Enter works
- Chat owns the single text state; card clears it on submit
- canSend for answer mode mirrors the card predicate directly — reading the
ref during render lagged one commit (found in browser testing)
Widths: one shared column (max-w-3xl + px-4) for transcript, cards, and
composer — all three measured pixel-identical (left 416, width 736).
Assistant Message max-w-full/MessageContent w-full (was max-w-[95%]/w-fit —
the 'cards are a bit shorter' bug).
User messages render plain text again — no math pipeline on learner-authored
text (currency hazard accepted for the model only, guarded by the prompt).
Tests: 58 pass (5 new composer pins; card tests updated to controlled
props). Verified live: note flow, answer flow, sidebar persistence, float,
fade, zero console errors — plus the live model spontaneously honoring the
$ currency escape contract in a continuation.
Layout:
- Home = knowledge-graph placeholder (main view) with new-session input
docked below; session list moved out of home into a hamburger drawer
- SessionDrawer: slide-over session navigation (backdrop/ESC close, active
highlight, fetches /api/chats on open) on both home and chat headers
- Chat header loses the back button — the drawer is the navigation
Math (inline $ was silently not rendering):
- @streamdown/math defaults singleDollarTextMath:false — now
createMathPlugin({ singleDollarTextMath: true }) in message + reasoning
- katex.min.css imported in layout (streamdown does not inject plugin
styles; display math only looked right via browser MathML fallback)
- QuizCard/AnsweredCard route question/options/explanation through
MessageResponse; user chat bubbles render markdown+math too
- Currency hazard: micromark-extension-math@3.1 lacks pandoc's
closing-digit rule, so bare '$5 and $10' parses as math — defense is at
the source: system prompt now instructs escaped \$5 / 'USD' for money
Hydration fix (React #418):
- QuizCard shuffled options with Math.random() — server/client orders
diverged. shuffleOptions now takes an injectable RNG; QuizCard passes
seededRandom(toolCallId) (mulberry32/FNV-1a in question-core). Same
question → same stable order across reloads; still varies per question.
Tests: 53 pass (5 new math-rendering pins incl. fenced-vs-one-line $$
behavior and the escaped-currency contract). tsc + next build clean.
Verified live in browser: inline/display math in text + cards, drawer
open/navigate/active-highlight/ESC, no console errors, no font 404s.
The teaching agent no longer runs on pi. Stack:
- ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL,
default anthropic/claude-sonnet-4.5 with reasoning tokens
- quiz/ask_user_question = client-side tools (no server execute): the call
lands as a typed tool part in input-available state; the card grades via
src/core/question-core.ts; addToolOutput sends the verdict; the teacher's
reaction continues the same assistant message
- resume-mid-quiz is free: the pending tool part persists in the transcript
- persistence: one JSON per chat (data/chats/), full UIMessage transcripts,
saved onFinish (also on abort)
- UI: AI Elements (owned copy-paste components, pruned to the five in use)
+ button-based QuizCard/AnsweredCard (ask answers never graded)
- pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom
protocol all deleted (recoverable from git history)
- tests: question-core units carry over; chat-flow integration drives
handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens);
render smoke tests guard the cards (renderToStaticMarkup)
Carried over unchanged: teacher system prompt, question-core, schema/
ledger/derive/extract pipeline, data model.
- Markdown.tsx never passed text as children to ReactMarkdown (valid JSX,
compiled clean, rendered nothing) — the 'thinking shows, text doesn't' bug
- toolResult entries (quiz/ask dispatch instructions for the LLM) leaked
into the transcript as visible text; now never rendered
- render smoke tests (renderToStaticMarkup, no DOM): pin markdown content,
KaTeX, button-based quiz options, ask answers not graded, toolResult and
[Quiz answer] hiding — negative-tested against the actual bugs
- React default import in all .tsx: tsx test runner uses classic JSX
transform (ignores tsconfig jsx option), Vite uses automatic runtime;
explicit import works under both
- katex css moved to main.tsx (tests can't import css)
- verified live in browser: all assistant messages render, ask card has no
grading marks, pending quiz re-arms on resume
Root causes of the v1 browser bugs (all fixed + covered by deterministic tests):
- duplicated messages: per-socket snapshot + global broadcast marking raced;
replaced with a single append-only entry log (exactly-once per socket,
structurally) — covered by tests/server.test.ts
- delayed/missing echo of own messages: optimistic append with sending/failed
status, reconciled by text when the real entry arrives
- no thinking shown: thinking deltas streamed live (kind: thinking) and
persisted in assistant entries so they survive reloads
- ask answers graded as wrong: AnsweredCard no longer applies quiz grading to
ask answers (no ✓/✗, no verdict)
- broken option selection: native radio/checkbox inputs fighting React state
replaced with button-based options (single source of truth, one-click select)
- no URLs: History API routing — / home, /s/<file> per session (deep links,
refresh, back button); session view resumes its file on mount
- lost connection = dead UI: exponential-backoff reconnect with full resync
- stick-to-bottom transcript with jump-to-latest pill
Server: startServer() with injectable runtime factory (scripted model in
tests); createSession returns file for routing; scripted model gained thinking
turns. 51 deterministic tests green.