STEERING, NOT FOLLOW-UPS — pi-agent-core's two rails, used deliberately:
agent.steer() injects at the next response boundary INSIDE the live run
(one logical turn, the model changes course); followUp() would only extend
an ended run — not built. The same rail job markers already ride
(steeringMode 'all', the 2026-09-26 doctrine).
- POST /api/chats/[id]/steer: 404/400 honest guards; no live turn → 409
and the client silently falls back to a plain text send (the settle
race); live → steerLiveChat hands the message to the live agent's
steering queue AND parks it in the userSteers registry — the stranding
safety net. A steer stranded behind a question termination or abort
stays parked and rides the chat's NEXT run (handOffEligible), the same
deliver-or-requeue lifecycle as job markers. Reconciliation at settle:
delivered ids drop (reconcileUserSteers).
- Client: steer(text) queues + POSTs without flipping any turn state;
the drain's verbatim message events upsert the real user card by the
client-minted id (steer_<localid>) and the indicator fold drops the
line. canSend no longer gates on turnInFlight — Enter mid-turn steers;
Enter with an empty composer still stops; the square button stays Stop.
- The queued-steer indicator (owner spec): quiet one-liners above the
composer shell — right-aligned to where its corner rounding starts,
truncated with an ellipsis, fixed max width, ~half opacity, no
background, no container. Tunable later.
- Verified from pi-agent-core source: steering drains per completed turn
inside the loop; injected messages emit message_start/end verbatim;
finishTurn {action:'end'} (our question stopper) exits BEFORE the
steering poll — hence the registry.
Two owner rulings, 2026-10-01 ('nothing gets lost' round):
1. SUBMIT FINISHES DICTATION FIRST. Every submit path — the dock's
answer (Enter/Cmd+Enter/button) and the composer's send — stops any
live run and lets Deepgram's last finals land in the target field
BEFORE building the payload. Dictation.finish() resolves at cleanup,
post-flush; a module finisher registry (same pattern as the question
target) hands it to the dock without a prop bridge. Field values are
read from REF MIRRORS (thinkingTextRef/answerTextRef, the composer's
textRef): the flush lands via setState after an await, invisible to
the closure. The quiz flow the owner described now holds: dictate
reasoning → pick answer → pick confidence → Enter → dictation ends,
reasoning saved into the thinking field, THEN the answer submits.
The composer stays sendable while a run is live (its spoken text is
deferred in the run's buffer) so Enter can trigger the finish-first
send at all; nothing said → the send no-ops.
2. RUN END FOCUSES THE BARE COMPOSER. The falling edge of
dictation.active focuses the composer's textarea — only when the
composer is mounted: a pending question unmounts it (ref null, no
focus), so the thinking field is deliberately left alone. Composer
gains an optional inputRef for the page's focus handle.
The Accept/Decline verdict gate is gone (owner ruling 2026-09-26):
presenting the plan ends the teacher's turn; the learner's next message
is a natural reply. Teaching begins at the first quiz call tagged with a
nodeId (probe quizzes carry none) — both folds (view phaseAt3, native
phaseAt3Lh) advance on the call itself, and the teach divider renders
before that whole assistant block. No backwards compat: verdict markers,
the verdict input event, optimistic appends, composer buttons, and
projection all deleted; prompts and docs rewritten to the natural flow.
Owner round 2026-09-25, over 09c0f6e. The wave's tooltips read as
distracting; this round makes them quiet:
- Delay: TOOLTIP_DELAY_MS = 500 in the primitive — tooltips appear when
you pause on a control, never in passing (Radix focus-opens stay
instant by design; keyboard users don't wait).
- Vanish on leave: disableHoverableContent — the chip is un-hoverable,
so the instant the cursor leaves the trigger it's gone, even mid-move
onto the chip's own pixels.
- Look: bg-accent (#2a2a2a, the hover-chip tone) instead of the white
bg-foreground slab; 11px; px-2 py-1; arrow culled (nothing purely
decorative); text-foreground/85.
- Suffix: parentheses, not em-dash — "Dictate (⌘D)"; the registry
source unchanged (still override-aware).
- Drops (owner-selected): send square, start square, stats summary —
the icons/numbers describe themselves.
- Home logo kept but to the RIGHT with its real shortcut "Home (⌘G)"
(jump-graph routes to /, the same destination).
- Trims: "Model selector (⌘K)", "Thinking level", "Graph costs —
click for details", "How much of this session isn't mapped yet".
- Added: Import Notes gets "Import notes into the graph" — the one
button whose destination wasn't obvious.
Pins: TOOLTIP_DELAY_MS=500; hintText parens expectations updated.
Live-verified on :3001 — 508ms hover delay measured, close within 10ms
of pointerout, chip styles probed (rgb(42,42,42), 11px, 4px/8px), arrow
gone, all new labels + sides confirmed, drops carry no tooltip and no
aria-describedby.
Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill
controls ride the styled Radix tooltip via a new Hint composition
(components/ui/hint.tsx) — the shared primitive adopted as-is, no
restyling; the shortcut appends as an em-dash suffix off the LIVE registry
(a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D".
Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop.
Native title= stays only for truncation-reveals (dock spans, the rename
field) and menu-interior abbreviations (MenuShell untouched). One
em-dash pattern; per-state truth on the send square (busy = "Stop the
reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept,
"Stop dictation (Esc)" kept verbatim per the owner's flag.
Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary
anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx
(hintText composition, asChild no-wrapper-DOM, the auto-update hint
constant); dock truncation titles pinned native; graph-chrome + settings
pins updated to the new copy.
Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag);
glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵,
Esc); per-page probes confirm Settings entry, Home logo, session search,
Start session, Close sidebar, model + thinking pills, stats summary,
rename title, plan chip, coverage ring, send square, cost button, node
search (⌘F), graph gear, settings close (Close — Esc), keyboard section
"Fixed — part of this screen", auto-update hint, light-mode "Coming
soon", the three slot rosters, graph-tools hint. Esc still closes the
settings dialog.
Three owner fixes for /chat/new (refinement round 4, 2026-09-24) —
the page must stop looking like a session that's already running:
1. NO PHASE CHIP: the header's <PhaseChip phase="probe" /> dies —
no session has started, so no phase exists yet. Active sessions
keep their chip (chat.tsx's derives from the live timeline).
2. TOP BAR STRUCTURE MATCHS SESSIONS: the header gains the same
border-b border-border as the chat header (h-15, lh-drag,
60px). The floating-bar look is gone; /chat/new now reads as
the same shell with empty content.
3. COMPOSER PLACEHOLDER = THE OPENING QUESTION: active sessions
keep "Ask, answer, or steer the lesson…" (now the DEFAULT of a
new Composer `placeholder` prop), but the fresh page asks what
the graph view's new-session input asks — "What do you want to
learn?" No lesson is in flight, so the composer must not read
like one is.
The ConversationEmptyState intro card ("The teacher will open by
probing…") stays, per owner confirmation.
Live-verified on :3001 /chat/new: no phase-chip node, computed
1px header border rgb(67,67,67) at h-15/60px, placeholder reads
"What do you want to learn?"; the empty-state card intact. Unit
test pins the default session placeholder AND the override.
903/903.
The shortcut architecture (owner-approved plan, 2026-09-17):
- src/keyboard.ts: pure action registry (id, label, scope, default
binding) + lh:keymap localStorage overrides + event normalization.
The settings milestone renders this same registry as its editor.
- components/keyboard-shortcuts.tsx: the single document-level keydown
listener (window level, bubble) owning the pieces no surface owns:
- Tab is DEAD (owner decision): browser tab-focus navigation is
abandoned; Tab is suppressed everywhere, Shift+Tab cycles the
thinking level.
- Esc is a layered dismiss: topmost popover first (popovers
stopPropagation their own Esc — usePopoverDismiss + ModelControl),
then dictation stop (lh:kbd-stop-dictation), then abort of an
in-flight turn (lh:kbd-abort-turn; server-side wake turns are
honestly non-stoppable — the seam only stops local streams).
- Global actions dispatch lh:kbd-<action> window events (the
lh:chat-renamed pattern); surfaces that aren't mounted simply
don't listen.
- The input guard: inside a typing surface only mod/alt combos (and
Tab) fire; bare keys pass through to the field.
- Cheat sheet overlay (?) renders from the registry, grouped by scope.
Bindings wired this wave: cmd+d dictation toggle, cmd+k model picker,
shift+tab thinking cycle, cmd+b sidebar, cmd+shift+f sessions search,
alt+up/down + alt+enter session cycling, cmd+g graph view, cmd+n new
session, f2 rename (chat), ? cheat sheet. Wave B adds the question
keyboard machine; wave C the per-surface bindings (find, import).
Verified live in the dev shell (CDP): all of the above via
Input.dispatchKeyEvent.
Co-Authored-By: Claude Fable 4.5 plus-plus <noreply@anthropic.com>
Two-band architecture from docs/research/stt.md: renderer captures mic
audio in an AudioWorklet (linear16 16kHz mono, ~128ms frames, per-quantum
RMS for the meter) and streams it directly to Deepgram over WebSocket —
audio never loops through our server. The real key stays server-side;
/api/stt/token mints a short-lived grant token (TTL 300s) per run.
Patterns ported from the owner's pi dictate extension (tuned in real
use): generation counter guarding every async handler, the
latest-RMS-never-resets meter (-50..-10dB window, 60ms tick), mic-first
stop + CloseStream + 3s safety net, endpointing=300 with the
utterance_end_ms=1000 backstop.
UI (owner placement): mic button in the stats bar's middle slot on all
three surfaces (home graph-view, chat, draft); while recording the meter
unfurls to fill the row's free width with the interim ghost at its
flank. Esc ends dictation from the composer. Token mint comes before
getUserMedia so a keyless app fails fast without a TCC prompt; one
automatic reconnect on abnormal socket close, errors surface inline.
Live-smoked the full error path in the dev shell (bar renders on all
three pages, 403 grant failure shows inline, mic untouched). The full
audio path needs a Deepgram key with Member permission to mint tokens.
- lh-input: lh-prose's 1.5 reading leading made typing carets tower
(~25px box on 16.5px text, ~2.1× the glyphs — the caret fills the line
box in Chromium). Inputs are not paragraphs: 1.25 leading drops the
caret to ~21px, the font-metric floor (as close to the glyphs as
Chromium will draw). Swapped on every typing surface — composer
(chat + home), the thinking field, answer + editable-Other inputs, the
model search; rendered prose (message.tsx container, option
descriptions, chat.tsx pre-wrap) keeps lh-prose 1.5. Same family and
size as prose; only leading differs.
- Session titles: 14px (text-base) — Source Sans at 12.5px read as
small as the 11px mono meta under them; the title now holds its rank.
Live: title "Source Sans 3" 14px vs meta "JetBrains Mono" 11px, row
52px; composer line box 20.625px (was 24.75px).
Gates: typecheck, lint, 469/469.
- ✓/✗ in the answered card: replaced literal glyphs with lucide CheckIcon/
XIcon (strokeWidth 2.5) inside a fixed size-4 flex box — text elements
never carried the right size/vertical alignment, vectors do. Test pins
updated (lucide-check/lucide-x classes).
- All user-typed INPUT text now matches markdown output: lh-prose class
replaces the earlier font-prose (family-only) band-aid on the composer,
question-dock text/thinking/Other fields, home topic input, model search
box, and answered-card 'your thinking'/free-text records. lh-prose sets
family+size (16.5px) so placeholders inherit correctly.
- Home view: StatsBar under the composer (model+thinking control) — the
DOCK geometry matches the chat view (max-w-[46rem] px-4) so nothing
shifts when a session starts; pre-chat selection persists via deferred
save (ModelControl chatId-empty) + PUT after POST /api/chats.
- Answer marks (✓/✗): same visual size as row text, vertically centered;
colored green-500/red-500 matching the verdict chips.
- All user-typed inputs (composer, question-dock text/thinking/Other,
answered-card 'your thinking' and free-text records) now render
Source Sans 3 via the font-prose utility. Placeholders inherit the
family via the element.
- Answered-card explanation: markdown wrapper made truly inline so the
'Why:' label + the paragraph sit on one line — removes the oversized
leading gap.
--text-xxs (10px) joins the ladder; every component-level text-[9|10|11px]
literal in owned components now pulls from the ladder (graph-view.tsx and
home.tsx untouched — graph stream). Cosmetic pins updated where touched.
The accept-plan UI lands (docs/probe-teach-split.md §4, formerly the
interim rule): presenting the plan no longer moves the phase — the
learner's ACCEPT does. While the presented plan awaits its verdict, the
composer's send square becomes two stacked buttons (green Accept plan /
red Decline); typed text rides the verdict as his note, Enter keeps
sending plain side questions (phase unmoved), decline asks for a
revision and the buttons stay.
The seam: accept sends a user message carrying a data-plan-accepted
part (PlanAcceptedData in contracts/chat.ts, TeacherUIDataParts
'plan-accepted'); phaseAt3 reads it as the plan→teach event (plan_init
demoted to presentation-only); convertToModelMessages drops data parts,
so the teacher sees only the text. Mid-lesson rewrites need no fresh
verdict — acceptance happens once, at the first presentation.
The teacher's planning prompt now ends the turn at presentation and
waits for the verdict (never teaches into an unaccepted plan).
Gates: tsc clean, 304/304 (phases tests rewritten to the acceptance
seam incl. decline/side-question/failed-init cases; composer verdict
render tests), lint, build. Live GLM smoke ALL PASS: phase pinned at
'plan' after plan_init, presentation turn carried NO check, accept
click flipped the summary to 'teach', then tagged checks moved the map
(current=rates, visited=composite); zero console errors.
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).
- 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.