Commit Graph
85 Commits
Author SHA1 Message Date
amosblomqvist 2d7b004fda fix: live sub-agent observability — dispatch cards, steer artifacts, sidebar panel
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).
2026-09-02 02:34:02 +03:00
amosblomqvist 96da582d35 Hover polish: constant-radius glow (opacity fade only), instant text swap
- The halo no longer grows during the fade: radius is constant at
  size + 9px; only its opacity fades in/out over ~180ms.
- The hover name is drawn opaque in the bright color instead of
  cross-fading over the muted resting label. Partial-alpha text
  oscillated whenever hover toggles rapidly at a small node's hit
  boundary (sigma fires enterNode/leaveNode on every state change) —
  an un-animated color can't flicker.
2026-09-02 02:31:08 +03:00
amosblomqvist 0a8c360546 Hover glow: static (no pulse), fade-in/out transition, flush with disc edge
- The glow no longer pulses: it's a static halo (9px beyond the disc,
  0.45 alpha at the edge fading outward) in the node's state color.
- Hover enter/leave now cross-fades over ~180ms instead of appearing
  instantly: entering schedules sigma renders so drawHover repaints at
  the interpolated fade; leaving draws through a repainter captured on
  the last hover paint (sigma stops calling drawHover once unhovered).
  The text cross-fades too — the brighter hover name is drawn at
  partial alpha over the muted resting label on the layer beneath.
- The halo starts flush at the node disc edge (was size+1, leaving a
  dark gap ring that read as a gray border between node and glow).
- Both fade directions cancel each other and stop on completion, so
  there is no continuous loop — idle CPU stays near zero.
2026-09-02 02:22:18 +03:00
Amos aa14351474 Hover affordance: pulsing node glow + brighter name, drop the dark pill
- defaultDrawNodeHover no longer paints a background box: the hovered
  node now pulses a soft radial-gradient halo ring in its own state
  color (evenodd punch-out keeps the node disc untouched), phase driven
  by performance.now() inside the drawer.
- The hovered label text brightens (muted-foreground -> foreground) at
  identical geometry — drawName is shared by both drawers so nothing
  shifts when hover begins/ends.
- The pulse is animated by a rAF loop that only runs while a node is
  hovered (enterNode starts, leaveNode/leaveStage stop, cleanup
  cancels) — idle CPU stays near zero, preserving run-then-freeze.
- roundedRect removed with the pill (its only remaining consumer).
2026-09-02 01:57:25 +03:00
Amos 4a5bc40243 Session picker: one phase-colored dot, state via fill + motion
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.
2026-09-01 19:49:47 +03:00
Amos 475726a976 Layout + graph interaction fixes
- 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.
2026-09-01 19:02:18 +03:00
Amos 08e417134b Fix hover pill geometry; fade labels in/out over a zoom span
- roundedRect typo: the first arcTo's second control point used y+width
  as the x-coordinate (y + width instead of x + width). Since arcTo
  tangent math is in absolute coordinates, the pill outline sprouted a
  stray wedge/notch whose size scaled with the pill's on-screen
  position (diagnosed live by the tester subagent: ~1000px left spike
  on a top-right node, right-side bites near the left edge).
- Labels now fade instead of popping: fully visible at fit zoom or
  closer, then the labels canvas layer's opacity is interpolated across
  a 0.5 camera-ratio zoom span before renderLabels switches off, with
  the setSetting call guarded so pans don't spam refreshes.
2026-09-01 19:01:25 +03:00
Amos 826de071dc Plan graph: the expanded mermaid-style view of the plan tree
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).
2026-09-01 18:34:58 +03:00
Amos fbe4f16305 In-session phase presentation: header chip + dotted boundary dividers
- 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.
2026-09-01 18:31:01 +03:00
Amos 05359301e2 Session picker: phase strips, status dots, live polling
- 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.
2026-09-01 18:28:35 +03:00
Amos 42c112f8c5 Labels appear all at once: one global zoom gate, no per-node size gating
labelRenderedSizeThreshold gated each node's label by its own rendered
size, so big (taught) nodes showed names several zoom stops before
small (candidate) ones. Zero the per-node threshold and instead toggle
renderLabels from the camera ratio: every name appears at fit zoom or
closer and they all vanish together when zooming further out. Hover
still reveals any node's name regardless of zoom.
2026-09-01 18:25:04 +03:00
Amos 6bfec1555d Graph labels: wrapped names, dark hover pill, node-anchored detail card
- Custom sigma label drawers replace the defaults: every label renders
  the node name word-wrapped under the node (max 170px line, overlong
  words hard-broken, never truncated) instead of one long strip to the
  right of the node.
- Hover swaps sigma's hardcoded white box for a dark rounded pill using
  the app's popover tokens; hover always shows the name, even below the
  zoom threshold.
- labelRenderedSizeThreshold 12 -> 6 (names are short now) and labels
  use the app's body font at weight 500.
- Click card anchors just below the clicked node's viewport position
  (flipping above near the bottom edge, clamped horizontally, measured
  in a layout effect so the flip never flashes); any pan/zoom dismisses
  it via the camera 'updated' event.
2026-09-01 17:55:44 +03:00
Amos 2e4e43b8c4 Graph nodes gain a name; nodes are objective facts (no learner/session references)
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.
2026-09-01 03:34:47 +03:00
Amos 49a6a6d9a0 Match the pi learning system's tool definitions (quiz/ask)
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.
2026-09-01 03:00:23 +03:00
Amos a7ca33548b Sessions keep running across navigation: turn liveness + write-ahead persist
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.
2026-09-01 01:57:13 +03:00
Amos e92140f993 Shell/core import law: public API barrels + ESLint enforcement (stage 2)
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.)
2026-08-31 19:51:17 +03:00
Amos 53be8a2834 W7: persistent right-panel plan tree 2026-08-22 11:37:28 +03:00
Amos 8728573253 W4: graph store + emission + home graph view 2026-08-22 10:30:00 +03:00
Amos 9a99b01159 W5: probe/teach phases — divider + collapsed teach checks 2026-08-22 10:26:51 +03:00
Amos 6f1b35b2c2 W6: demo + visual artifacts, verifying builders 2026-08-22 10:24:02 +03:00
Amos 6a68fcfba1 Wave 2 prep: LedgerEvent.ref, demo bridge protocol, artifact stubs, sendText in ChatActionsContext 2026-08-22 10:03:20 +03:00
Amos 34dee919bb Fix job continuation: setMessages never fires sendAutomaticallyWhen
Gate 1 found the teacher staying silent after a research brief landed.
The SDK only evaluates sendAutomaticallyWhen inside addToolOutput — an
external setMessages (our syncFromServer) updates the UI but never
triggers the continuation. Fix: syncFromServer checks
lastAssistantMessageIsCompleteWithToolCalls and calls sendMessage()
(itself a valid continuation trigger); plus a mount-continuation effect
for the reload case (job fulfilled while no client was open).
2026-08-22 09:58:41 +03:00
Amos 2c522116c0 Gate 1 integration: shared streamdown config in reasoning, teaserText flattens \(...\) 2026-08-22 09:42:39 +03:00
Amos ef488224bc W3: presentation fixes — delimiters, line breaks, mermaid
- 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).
2026-08-22 09:38:54 +03:00
Amos df16750560 W2: async job infra + researcher sub-agent
- 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
2026-08-22 09:36:15 +03:00
Amos 6d9ca54d6b Gate sending while a background job part is pending (protocol §5) 2026-08-22 09:16:02 +03:00
Amos c55bae0de6 Wave 0: shared contracts + extension points
- src/core/contracts.ts: plan tree / graph / ledger / job protocol / artifact
  registry types — the single source of truth for all workstreams
- tools: per-request factory createTeacherTools({chatId}) composing
  questionTools + planTools (stub) + jobTools (stub); quiz gains nodeId
- prompt composition: system-prompt.md + sorted src/agent/prompts/*.md
- ChatRecord.tree + saveTree; pending gate scoped to quiz/ask (+findPendingJobCall)
- chat.tsx: artifact renderer extension point + ChatActionsContext.syncFromServer
2026-08-22 09:06:24 +03:00
Amos 3c4eac8061 Fix bar-click flicker: suppress collapse while the reveal scroll is in flight
Clicking the collapsed bar set expanded=true and started a smooth
scrollToBottom — but the scroll position was still legitimately inside
the collapse zone (that's why the bar was showing), so the hysteresis
listener re-collapsed the dock on the very next scroll event, then
re-opened it when the scroll arrived: open → snap shut → reopen.

The machine had no notion of intent. Now the bar click sets an
expandIntent flag that suppresses the collapse branch; it clears when
the scrollToBottom promise settles (completion or interruption) or after
a 2s safety net, so a user yanking the scroll away mid-reveal collapses
the dock again instead of wedging it open.

Verified live: click = exactly one transition (bar→open at ~26ms, stays
open, lands at d=1); collapse still healthy afterward; mid-reveal
interrupt re-collapses within ~300ms.
2026-08-22 06:56:39 +03:00
Amos d1d28d6bb4 Session switch: land at the bottom instantly with the question dock open
- Chat now remounts per session (key={chat.id}): fresh StickToBottom,
  fresh DockOverlay (no cross-session morph from the previous shell
  height), fresh useChat.
- Conversation initial="instant" (was "smooth"): the mount scroll to
  bottom is a jump, not a slide.
- DockOverlay skips the pending-question scroll/expand effect for the
  session's INITIAL pending question (dock starts expanded; the initial
  scroll positions us) — previously it fired a second smooth
  scrollToBottom right after the library's initial scroll (the visible
  'pause, then the quiz pops and pushes everything up').
- The dock-variant fade/rise no longer plays for the session's opening
  variant; reserved for mid-session transitions.
Verified live: client-side switch settles at d=1px from the bottom within
one frame of content swap, dock open from the first frame, both
directions (quiz session, plain session).
2026-08-22 06:45:32 +03:00
Amos 939e1e2e5e Scroll behavior: hysteresis collapse, expand-scrolls-to-bottom, streaming follow
- Oscillation fix: collapsing the dock frees ~Δpx of flow height; within
  Δ of the bottom the browser clamps scrollTop to the new max, landing
  the learner AT the bottom, which re-expanded the dock → loop. Now a
  hysteresis state machine on the scroller's scroll events: collapse only
  when distance-from-bottom > Δ+64 (strictly clamp-proof), expand only
  within 16px of the bottom. ~370px dead band; exactly one transition per
  direction verified live. Heights measured live per question.
- Reaching the bottom re-expands the dock AND scrolls to reveal it fully
  (previously it grew over the content and left you short of the bottom).
- Streaming follow fixed: ConversationContent had h-full, so the observed
  content box never grew and use-stick-to-bottom's ResizeObserver never
  fired — no auto-scroll during streaming. min-h-full restores growth.
  Verified: distance-from-bottom ≤70px transient during a 25s stream,
  settles at 1px.
2026-08-22 06:22:40 +03:00
Amos aeef28d4d9 UI iteration 4: dock morph animation, sticky-in-flow dock, scrollbar, indicators
- 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.
2026-08-22 05:56:52 +03:00
Amos cd789c11b1 UI iteration 3: question dock — pending questions morph the composer blob
- 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.
2026-08-22 05:02:15 +03:00
Amos 0baa00e418 UI iteration 2: toggleable sidebar, floating composer blob, pending-question morph
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.
2026-08-22 04:28:13 +03:00
Amos 46d71380fa UI iteration 1: app shell + full LaTeX pipeline
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.
2026-08-22 03:23:11 +03:00
Amos a72c902d30 v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app
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.
2026-08-22 02:18:27 +03:00