The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded
enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never
named the expected values, so the model re-emitted the identical payload
until the dev server was killed. And mid-turn the learner was trapped:
Esc's question/problem rungs only dismissed — the dismiss route 409'd
against the turn-owned record and nothing could stop the running turn.
- healEncodedArguments (practice.ts, the prepareArguments seam): a STRING
where the schema expects a literal union / boolean / null / number /
array is JSON.parse-d and kept when it satisfies the schema. Valid args
pass through untouched (same reference); unhealable values stay for
validation to reject honestly. Wired on BOTH problem and conclude.
- dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202);
the turn's settle path (onFinish, after persistTurn, before endTurn —
the same ordering law as the record write itself) applies it against
the persisted record, only if the dismissed thing really is pending.
- confirmAbort: question/problem discard while busy now ALSO aborts the
in-flight turn; the dialog line says so (busy prop).
No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai
validateToolArguments end-to-end; park registry, apply paths, the 202
route contract).
Owner report, 2026-10-04: starting a session with map picks showed ONE
empty user bubble, no agent turn; a follow-up woke the agent with the
right material but the marker never rendered. Two breaks, one root —
the record no longer starts empty and user-side data parts had no
render lane:
- The first-send gate checked initialMessages.length === 0; the
graph-context marker at message[0] made that false, so the ?prompt
turn NEVER fired. New pure helper initialPromptOwed (the escAbortTarget
pattern): the prompt is owed while no user/assistant message exists —
scaffolding is not conversation. Pinned in tests/graph-context.test.ts.
- The transcript's user branch rendered text parts only; a data-only
message (graph-context, practice-thinking) fell through as an EMPTY
bubble — the data-practice-thinking case I anchored to lived in the
ASSISTANT switch, dead on arrival. Data-only user messages now hoist
to a bubble-free card lane (the subagent-steer precedent); the two
dead assistant-switch cases are gone.
Gates: 1296 pass / 0 fail; typecheck, lint, build clean.
The map picks no longer glue into the opening prompt as seed text
(owner, 2026-10-04 late). POST /api/chats accepts graphContext
{propositions}; the route resolves the picks against the live graph,
derives the reasoning chains among them (chainsForSelection →
formatChainLine), and writes a graph-context custom marker as
message[0] — before the learner's first user message, in either mode.
- Core: newGraphContextMessage (model-facing content = the
contextualization framing + proposition lines + chain lines;
details = the structured pick); toLlmMessages flattens it to a user
message like every marker, so the model reads it for free.
- View: projects to a user-side data-graph-context part.
- UI: GraphContextCard — minimal, muted, dashed, collapsed by default
(counts only); expand reveals name — statement rows and the chain
lines. Same compact-expandable pattern as the practice cards.
- Home: sends graphContext with the creation whenever picks exist;
the prompt param carries the learner's own words only.
- needsContinuation: a persisted problem-pose ack (terminate:true ended
the turn deliberately) owes no continuation — reloads after a pose no
longer wake a phantom turn; error results stay owed like any
interrupted tool.
- Chat header: practice sessions swap the phase chip for a quiet
Practice tag (no probe→teach arc applies) and hide the coverage ring
(practice never extracts — an ever-filling ring would lie).
- ChatSummary gains pendingProblem (activeProblemOf at projection); the
sidebar meta says 'Problem waiting' for a practice row with a posed
problem. Kind threads through Chat/[id] page.
- Home picker chip gains Clear (picks were otherwise irremovable).
- PracticeDock (components/practice-dock.tsx): the active problem
replaces the composer — statement (markdown+LaTeX) above, the 'Your
thinking' composer-twin below with the two submit lanes (Nudge =
thinking without a commit; Commit = answer commit; ⌘/Ctrl+Enter
commits). Dictation claims the thinking field.
- Transcript cards (practice-cards.tsx + the artifact registry): the
problem card (compact-expandable statement — the commitment NEVER
renders), the conclude card (outcome + verdict + reveal; the
dismissal synthesis is one muted line), and the user-side thinking
register cards (Your thinking / Your solution).
- view.ts: the problem pose joins the placeholder fork (the ack never
flips the part); practice-thinking markers project as data parts;
problem-dismissed markers project nothing.
- chat.tsx: activeProblemOf drives the dock branch (question > problem
> composer), the esc layering gains the problem rung (below question,
above turn) with its discard confirm + the silent marker route; the
thinking submissions POST through the session's optimistic appends.
Esc over a mermaid focus, the plan popup, or the setup dialog opened the
question-dismiss confirm underneath — KeyboardShortcuts' window listener
registers first, so the overlays' own self-closing Esc listeners could
never outrun the lh:kbd-abort-turn dispatch. The state gate in ChatView
is the defense (the architecture's own pattern from the abort dialog):
escAbortTarget, exported pure, rules priority by stacking order — abort
confirm (claimed by its dialog) > mermaid (paints above plan) > plan >
setup (self-closing) > question > turn. The gate closes the overlay; the
overlays' own listeners stay (idempotent). Pinned in esc-layering.test.
The teaching cycle gains its motivation question. The teach quiz schema
now carries a REQUIRED type field — predict (posed before the teaching,
arrives open, the learner predicts the next step) or check (posed after,
arrives collapsed behind the reveal). The prober's quiz stays role-less.
- core: quizInputObject.type union + normalizeQuizType + the reveal law
as pure quizNeedsReveal; QuestionData.questionType; note → thinking
rename (legacy reads fold back); readingThinking channel on answers;
compactAnswerForModel emits thinking / thinkingWhileReading
- tools: descriptions stripped to mechanics — QUIZ_OPTION_CRAFT and
EXPLAIN_DOCTRINE deleted, doctrine moved to the prompt layer
(reverses the 2026-09-27 placement, owner 2026-10-03); shared
quizDescription/explainDescription builders differ only in deltas
- prompts: shared/20-question-tools.md rewritten (option craft,
transcribed-speech leniency, the two thinking channels); new
teacher/28-predictions.md (cycle, roles, skip rule, resolution law);
20-teaching + 30-question-principles scoped to the new taxonomy
- UI: predictions render open with a Prediction eyebrow, verdict shows
but the explanation tucks behind a toggle; the reading-thinking field
mounts while a check waits (dictation claims it until reveal);
answered card separates thinking vs thinking-while-reading blocks
Root cause: doSteerOrSend branched on turnInFlight, but the local dedup
(chat.tsx ~370) clears that flag the moment the own stream takes over —
during our own turn busy was true and turnInFlight false, so Enter fell to
the plain-send path whose busy guard returned silently. Branch on busy:
own stream AND foreign/wake turns both have a live agent registered by the
time the send's POST resolved, so the steer lands (a raced settle 409s and
falls back to the plain send).
The button now follows the text: busy + typed text = Send (click steers —
steering must be clickable, not Enter-only); busy + empty = Stop. ESC keeps
its confirm-popup abort in every state, so stop is never unreachable.
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.
- {type:"dismiss"} input event: parse + pure appends with the answer-event
orphan discipline (dangling-call 400, kind match, answer must be dismissed)
- POST /api/chats/[id]/dismiss: the no-turn composition — write-ahead append,
409 while a turn runs, emits record-changed (new bus word; idle views sync)
- needsContinuation: an all-dismissed trailing batch demands nothing;
endsInDismissal = the derived pause predicate (tail-based, no latch)
- wake pipeline: while the record ends in a dismissal the pump holds every
delivery in the steer queue — the user's next send is the wake (until the
text-turn steer claim lands, settlement re-pump delivers right after it)
- session-core.dismissQuestion: optimistic skipped card + silent POST;
chat.tsx confirmAbort question-mode swaps submitAnswer for it; ESC-abort
popup everywhere (AbortTurnDialog) with popup-Esc claimed via state gate
- parallel question calls dismiss call-by-call (results bind by id)
- requiresReveal gates on pending.tool === 'quiz' (owner: only graded quizzes
arrive collapsed; ask/explain always open)
- ESC-abort chain opens AbortTurnDialog everywhere (owner ruling); from a
pending question abort IS dismissal — dismissedAnswer rides the answer
path, the teacher acknowledges the skip in its next turn; popup-Esc is
claimed via the state gate (two window listeners fire per Esc)
- 25-confidence.md: stripped the slider/JSON/calibration mechanics — pure
reading doctrine remains
- toolQuestionKind named once in core (dock parity), dock switched to it
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.
Owner UI round 2026-10-01, three rulings on the slider plus one en-route
bug:
- GLOBAL ←/→, the ↑/↓ pattern: confidence adjusts wherever focus sits
(window keydown, typing-guarded), no click-in, no focus ring. The
slider itself takes NO focus — tabIndex -1, mousedown prevented,
removed from the Tab chain (rows → thinking directly again).
- Position = fraction: the thumb's scale is 0-100 with the rendered
value the stop's own percent, so 50 sits halfway; onChange snaps to
the nearest stop (magnet feel while dragged).
- Six-step ramp (owner pick over three bands): 0 red, 30 orange,
50 yellow (the coin flip), 70 lime, 90 green, 100 emerald — fill AND
handle wear the stop's color; taller 10px pill bar, custom track/thumb
(webkit + moz), muted remainder via --muted.
- Plan-flank phantom: gating the marginalia button on a JSX element
(always truthy) mounted an invisible clickable button in the
bottom-right whenever a question was pending with no plan.
planExists() now gates it (pinned in plan-window tests).
- components/top-bar.tsx: THE shared top bar (standardization ruling) —
h-15, lh-drag + carve-outs, select-none, SidebarToggle reopen with the
Electron traffic-light gutter; adopted by chat (rename keeps its
drag={false} escape hatch), new-session, practice-list, practice-exam,
and the chains overlay.
- chain table: prominent indentation (pl-11 / pl-[4.75rem]), the ONLY
lines are the separators between cluster groups (row + vertical lines
gone), plain statement rows (no conclusion marker), all three columns
sortable (Props too) with a small chevron on the active header.
The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of
any pick, while composer and settings UIs agreed with each other and lied
about the run. Root cause: a fossil default-settings record (settings.json
chat section, frozen pre-slots-wave) was stamped verbatim into every new
chat at birth by the new-session flow, and resolveConversationSpec prefers
the per-chat record over the strong slot forever.
Owner redesign — models are per session, always; the strong slot is the
new-session default:
- POST /api/chats stamps the newborn verbatim from the strong slot
(model + thinking level); no client settings PUT on create anymore
- the composer control is dual-bound: in-session it reads/writes the
session's own record (GET /api/chats/:id, PUT .../settings); on the
draft surfaces it binds the strong slot (picking there = changing the
default)
- thinking levels follow the identical mechanics (confirmed by owner)
- the zombie default-settings system dies whole: useDefaultSettings,
/api/settings/defaults, src/store/default-settings.ts, the chat
section of UserSettings (sanitizer strips fossil copies), the legacy
default-settings.json fold, and DEFAULT_SETTINGS_FILE
- catalogDefaultsFor collapses to the strong slot alone
- thinking display for a legacy unstamped session resolves exactly like
the engine (session ?? strong ?? model ladder)
Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot,
the unset level, and the copy-not-live-view regression; defaults pins
rewritten strong-only; legacy-migration suite deleted.
Sycophancy mitigation as a structural commitment: the model writes its
full correctAnswer (plus explanation, in that order) BEFORE the learner
sees the question; the commitment rides the persisted call args, is
revealed on submit, and grades his free text in the model's next reply —
the client never grades free recall.
- tools.explain setting (segmented 'Quizzing — Multiple choice | Free
recall' in Agent settings' Tools group): free arms explain and drops
quiz for BOTH agents (prober variant carries no nodeId, same pattern
as the probe quiz); mechanics keys never pass filterEnabledTools —
quiz stays unwritable (the 2026-09-19 wedge ruling stands)
- doctrine lives pi-natively in the tool descriptions (owner ruling —
no conditional system-prompt chunks): explain carries the commitment
rule; the MC option-building craft moved out of the prompt into the
quiz description; shared/20-question-tools.md slims to the
quiz-agnostic core
- display seams: parseExplainInput (LaTeX-debris restoration included),
dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard
reveal (The answer / Why, no verdict chips), [Explain answer] format
- phases/plan seams recognize both quizzing tools' nodeId tags; explain
answers deliberately feed no ledger evidence (no client grading to
key on — a future ruling can parse the model's grade)
1049/1049 tests, typecheck/lint/build green.
The inferred teach seam sat as a ternary branch in the messages.map
chain (alongside the no-bubble marker branches), so the ENTIRE first
teaching group — thinking, prose, the tagged quiz call — rendered AS
the divider and vanished below it (the Lacan session, 2026-09-27).
The marker branches replace contentless scaffolding; the teach group is
a real message. The divider now prepends inside the group's Fragment.
Also tightens 10-planning.md: the presenting turn asks NOTHING — no
quiz, no ask_user_question, no 'shall we begin?' (the teacher was
substituting a confirmation question for the dead verdict buttons).
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.
Two owner-reported symptoms, one mechanism (verified frame-by-frame on a
zero-model-call fixture): the position machine read its OWN growth-driven
scroll lag as the learner leaving.
1. Probe arrival at the bottom: the form opened, the reserve grew, the
smooth catch-up lagged (d peaked ~405px > COLLAPSE_DIST 96) → phantom
fold → bar shrink morph → re-expand at bottom. FORM→BAR→FORM, ~600ms.
Fix: the at-bottom arrival now runs under the SAME fold suppression
the teach reveal uses (suppressFoldDuringReveal, extracted) — the
arrival's open and its scroll-to-bottom are the same motion class.
2. Probe arrival scrolled up: one stale-open frame painted before the
after-paint effect collapsed it. Fix: probeArrivalExpanded — the
open/closed state decided DURING RENDER (open iff at bottom), like
the teach seam; the effect keeps only the reveal scroll.
3. Dictation stop flush into the pending question: the field grows on
its own render (capped 192px), the reserve lands on the NEXT
DockOverlay render (~1s later live), and the spacer's growth spiked
d≈152 → fold → bar → re-expand. Fix: the offer stamps the geometry
mute when the reserve GROWS while pending — the spacer is the
phantom-scroll source, so the mute covers exactly its scroll events.
Shrinks never stamp (the resize round's stuck-collapsed regression).
Live-verified on :3001 (job-settle fixture, abort-channel settle, no
model calls): A at-bottom arrival FORM-only; B scrolled-up arrival
straight to BAR with zero FORM frames and no scroll-down; C dictation
flush FORM-only through the +152px reserve growth.
Three owner rulings from the gradient-descent session
(chat_muhskq6p_e8c3600b — 7 failed quiz calls, answers leaked in
option descriptions, planning divider floating at the transcript top):
1. RETRYING LINES (general): any tool call that errors back to the
agent (validation failure, malformed args — the retry loop) renders
one quiet "Retrying…" SystemLine instead of silence. Question tools
previously rendered null, so a flailing quiz loop read as a stall.
Aborts are terminal ("Operation aborted") and stay silent. Verified
live: exactly 7 lines render at the 7 failure sites of the session.
2. QUIZ OPTION DESCRIPTIONS: NUKED. The model used them to telegraph
correctness in every quiz of the session ("that moves uphill — it
climbs f instead of descending it") — answerable by elimination.
The field is gone from BOTH quiz schemas (probe + teach); ask keeps
it (ungraded — nothing to leak). The dock additionally suppresses
descriptions for quiz kind, so pre-fix records stop leaking too.
Verified live: the session's pending quiz renders bare labels.
Plus the diagnosed failure-mode fixes: GLM sometimes emits `options`
as a JSON-encoded string (6 of 7 failures). prepareArguments hooks
on all three question tools coerce it BEFORE pi's validation (the
call lands instead of bouncing); the client coerces again in
normalizeOptions (pi persists raw call args). Schema descriptions
hardened ("REQUIRED ... never a JSON-encoded string").
3. DIVIDERS AT THE MARKERS: the probe-handoff SystemLine
("Preparing your plan…") IS NOW the Planning divider; the accepted
verdict IS NOW the Teaching divider ("✓ Plan accepted" line gone) —
one signal per seam, not two. The fold-based divider block is
DELETED: Wave D grouping merged the whole probe into one giant
assistant group, so the inclusive-fold comparison floated the
divider to the transcript top (reproduced against the real record:
6 view messages, phase flipped at the giant group). The markers
render in the right spot by record construction. A DECLINED verdict
is not a seam — it keeps the "↻ Plan revision requested" line.
Verified live: plan divider after the probe concluded, teach
divider after the plan presentation, nothing at the top.
Tests: 1004/1004 — string-coercion pins (normalizeOptions + the
prepareArguments hooks on all three tools, identity fast path),
quiz-schema-has-no-description pin (ask keeps it).
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).
Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).
Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.
Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).
Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
The onSubmitAnswer path was the one Wave B send site missing its stash:
addToolOutput triggers the SDK's automatic continuation send, the
transport found nothing stashed, and threw — the answer never POSTed, so
the question stayed open. One line (plus the contract comment). Caught
live by the owner; the suite drives answer events directly at the
handler, so this client seam stays untested until Wave D's client work.
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
Reattach to a turn that outlived its view through the SDK's native
resumeStream() so replay burst + live deltas flow through the exact
same processUIMessageStream chunk pipeline as first-subscriber
streaming — no second rendering dialect.
chat-transport.ts: ReattachingChatTransport subclasses
DefaultChatTransport and repoints reconnectToStream at GET
/api/chats/[id]/stream (the server's reattach endpoint, not the SDK
default /api/chat/{id}/stream). It peeks the first SSE event:
- turn-settled terminal: cancel body, fire onSettled (refetch), return
null — the SDK treats null as no active stream, status stays ready.
- start chunk whose messageId equals the seeded last assistant message
id (continuation case): rewritten to a fresh id so the resumed run
mounts beside the seeded message instead of replacing its parts —
settle-time record sync merges to the server's assembled message.
chat.tsx: mount effect resumes when initiallyInFlight (no once-ref:
React StrictMode's dev double-mount aborts the first attempt at
dispose — the retry must run). Mid-session discovery sites (jobs
poll, handoff watch) funnel into attachToRunningTurn().
turnInFlight is demoted to fallback liveness: the 1500ms record poll
self-heals a failed attach and clears a stale error banner on settle.
A status watcher clears the flag once the local stream represents
the turn. Stop/Esc unified in abortInFlightTurn: local stop() when a
stream runs here, always POST /api/activity/abort {kind:'turn'} with
409 not_running treated as a benign raced settle. Composer busy now
covers reattached turns (Stop square shows, send gated).
Live-verified on :3001 with real model turns: navigate away mid-turn
and back (replay + live deltas in-view, fallback poll disengaged),
Esc-abort of a reattached turn (partial persisted server-side,
composer restored), two concurrent sessions (raced-settle handled
via turn-settled + record refetch; live settle adopts the final
record), and no leaked stream readers after unmount.
Owner report (2026-09-25): in the teaching phase a new question arrives
collapsed behind the reveal button, but the transcript scrolled up as if
it had OPENED — the room it made was the expanded form's height, never
shown. Root cause: the expanded state is stale-true from the previous
question (nothing resets it when the dock returns to the composer), so
the new question's FIRST paint rendered open; the dock measured itself
tall and offered that height to the spacer, and the collapse one tick
later could never give it back (fold immunity refuses drops while a
question pends). Collapsed bar, tall reserve — scroll as-if-opened.
Fix (owner-shaped, deliberately minimal): apply the arrival state DURING
RENDER, before the first paint (the render-time state adjustment
pattern) — revealArrival(wasQuestion) decides how a teach question
lands: after the composer → collapsed (the learner reads the teaching
first); after ANOTHER question (a queued parallel check) → OPEN — the
reading already happened, and the height it already has is the honest
height. The paint measures the truth, so the reserve never learns a
height that isn't on screen. Probe arrivals are untouched — instant-open
at the bottom stays. The old [pendingId] effect keeps only its probe
branch.
Live-verified on :3001 with a fabricated teach-phase session driven
through the real settle/adoption path (zero model calls): composer→teach
arrival lands as the 59px bar with the spacer growing 206→211 (+5) and
the transcript scrolling only 77px (the new message itself) — the
previous behavior jumped the reserve +339px and scrolled 660px;
teach-check reveal click still expands (425px) and scrolls to bottom;
a queued parallel question arrives open with the reserve unchanged
(577→577) and +40px scroll.
Pins: tests/reveal-arrival.test.ts — revealArrival(false) = collapsed,
revealArrival(true) = open (the owner ruling). 946/946.
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.
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:
1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
(classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
(GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
dispatches after save AND delete.
2. Setup stage 1 is self-contained: provider picker (MenuShell +
ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
re-runs on it, and the stage flips to the pickers on its own.
onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.
3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
parseModelsRefusal) joins the revert-draft-and-open-setup interception;
activity-dock reads 'needs a provider key'; dispatch-card shows
'Add a key' — both deep-link setup, which lands on stage 1 by itself.
4. Dictation trio: the token route's only 503 (missing Deepgram key)
becomes the client-authored DICTATION_KEYLESS short line; the errored
idle click clears the error and deep-links Settings -> Keys ->
DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
-> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).
Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
The scroll-to-bottom disc (ConversationScrollButton) was the scroller's
last child — DOM order painted it ABOVE the dock overlay, so it floated
at the scroller's bottom edge, partly covering the composer (owner
refinement round 3, 2026-09-24: it must ALWAYS sit above the composer,
never over any element, and stay positioned with respect to the
composer — which grows a lot taller when a question owns the band).
The fix is a DOM move, not a coordinate: the button now renders INSIDE
the DockOverlay, as a sibling of the DOCK container within the dock
block — `absolute bottom-full mb-2`, anchored to the BLOCK's top edge.
Because the anchor is the block itself (not the scroller's bottom):
- it sits above the composer shell AND the stats row, in the
gradient's transparent band;
- it rides every height change LIVE: typing growth, a pending
question expanding the band (verified: the disc moves with the
393px question form, 9px clear), the 180ms fold morph to the slim
bar (8px clear of the folded bar — no overlap mid-morph either,
height animations reflow per frame);
- pointer-events-auto (the overlay is none by default); the
isAtBottom/scrollToBottom context still reaches it — DockOverlay
is a Conversation child.
The use-stick-to-bottom pattern comment updated; the shared
ConversationScrollButton component itself is untouched (positioning
arrives via className, twMerge swaps bottom-4 → bottom-full).
Live-verified on :3001 with a tall transcript, all three states:
chat mode (15px clear of the composer textarea), expanded question
(9px clear of the form top), folded slim bar (8px clear). 902/902.
The owner-locked extraction-UX redesign (dispatch 2026-09-24), UI
chunk over Platform's data layer (eed4b4e — GET /api/chats/[id]
carries `coverage`, the threshold is Graph ▸ extractionChunkTokens):
1. SIDEBAR BLOCK (components/extraction-coverage.tsx +
sidebar's session menu): the coverage info block at the TOP of
the context menu — uncovered tokens, uncovered messages, running
state — then the Extract affordance (optimistic "extracting… /
Running" until the server says running; 409 = benign, the trigger
raced us), fetched ON MENU OPEN (the block mounts with the menu —
on-demand, never eagerly), polled at 2s while a run is in flight.
Visually separated from Rename/Delete by a hairline; those stay
the two session-management actions. Failed read → nothing (never
fake zeros).
2. RING METER (CoverageRing, chat top bar, before the PhaseChip):
a 16px donut filling uncoveredTokens / extractionChunkTokens —
"how close this session is to its next extraction run". Click →
the exact numbers (covered/uncovered tokens, messages, the
threshold, lastFoldedAt via timeAgo). Refresh chains: mount, the
turn-settle bump (chat.tsx coverageTick), the settings signal
(threshold/kill-switch re-read live), a 2s drain-poll while a run
is in flight, and the ACTIVITY FEED — a run for this session
appearing or settling in the dock's feed re-reads coverage (runs
fire from other surfaces — the sidebar menu, the turn pipeline —
and no turn-settle bump announces them; the runs-empty-transition
the graph view rides). Vanishes entirely when the graph is off or
before the first honest read. Running = the whole ring PULSES
(implementer's visual call: motion is the working-state signal in
this app's quiet register).
3. PHASECHIP UN-PILL: the phase label is a flat tag — no border, no
rounding, no padding. The Plan chip is the only button-shaped
thing in the top bar. Drag-surface (pointer-events-none) and aria
semantics unchanged; the transcript PhaseDivider keeps its look.
4. PANEL DEATH: components/graph-coverage.tsx, its graph-view
mount, and tests/render-graph-coverage.test.tsx die whole.
Confirmed: after this, NOTHING client-side consumes GET
/api/graph/coverage — the only remaining client use of the
coverage endpoints is the Extract POST (kept, by design); the
routes stay (Platform's call).
Pure seams exported for tests: ringFill (clamp math),
CoverageMenuBody, CoverageRingGlyph, CoverageRingDetail.
901/901.
Live-verified on :3001: ring fill arcs match the API byte-for-byte
(483/4096 = 4.45/37.70; 1991/4096 = 18.32/37.70); the sidebar block
reads "2k uncovered / 4 messages unextracted"; a real Extract run
went optimistic → dock run → menu settled "fully extracted" AND the
ring drained to the empty arc (the feed chain); graph off → the ring
vanishes; the flat phase label renders chrome-free beside the Plan
pill.
Part 1 — the surface:
- Settings ▸ Agent ▸ Tools gains the prober GRAPH-TOOLS row as its FIRST
row (owner ruling: in the Tools section, not the tool-toggles list —
its subject is the map, its mechanism is agent capability). Writes
agent.graphTools {enabled} (absent = on, the toggle discipline) via
the section PUT; composeGraphTools pure seam. Its own key in the agent
contract — never in the tools map.
- The row carries the search-row hint discipline: "Connects prior
sessions' knowledge into new ones — the prober reads the knowledge
map" (owner wording). The honest interplay composes both: when
graph.enabled is off the row dims + disables with a "Graph is
disabled" hint (the effective gate itself is server-side, per-turn
fresh). AgentSection reads the graph section (record.graph, passed by
the dialog) — read-only.
- Cluster labels wrap: wave 7 made labels session names and FILE PATHS,
which the old single-line fillText could not bound. wrapRegionLabel
(pure, exported): word-wrap first, char-split long paths (the
node-label discipline), capped at 3 lines, overflow folded into an
ellipsized last line (owner: wrap, cap 2–3, ellipsize). Pre-wrapped
once per geometry build at the SCREEN-fixed size (viewer-relative
labels — the wrap never goes stale mid-camera-move), drawn centered
line-by-line at the centroid. Deleted-session clusters keep their
hull-only honest silence (label null → no lines).
- The orphan sweep: StatsBar's leftSlot prop is dead (its one consumer,
the knowledge-context slider, died in c690181) — removed, and pre-chat
views now render NOTHING on the left (owner ruling this round: a
"— · —" summary over an empty popover is pure noise) — the dictation
middle just widens. Stale knowledge-context comments swept in
chat.tsx (adoption-window + activity-nudge docs), activity-dock.tsx
(useActivity ground rules), dictation.tsx (×2 adaptive-meter docs),
home.tsx (pre-chat dock comment).
Part 2 — Teacher's c690181 review: clean. Imports, KIND_ICON/KIND_LABEL
entries, render branches and event seams all excised whole; the probe-
handoff branch re-indented intact and merge-adopted; tests updated in
lockstep (merge-adopted, record-adapter, render-new-session retired).
Platform's 4b60e0f roster-string fixes (labeler dropped from both
SLOT_META blocks) reviewed — consistent across agent-section +
setup-dialog.
Gates: tsc clean, 854/854 (8 new: wrapRegionLabel cases, graphTools
SSR rows ×3; stats-bar + ladder fixtures updated to the new rulings),
lint 0, build ok. Live on :3001: graphTools row first-in-group with
hint, off→{enabled:false}→on round-trip, graph-off dimming + hint +
disabled toggle, pre-chat rows summary-free, chat view keeps its
summary, long-title cluster label wrapped without console errors
(visual read unavailable this session — pure wrap is unit-pinned).
The owner's zero-configuration ruling (2026-09-23): nothing configures
the graph beyond chat models. The prior-knowledge injection — its budget
dial, its runner, its marker part, its dock/dispatch entries — is
removed across all bands; the prober now reads the learner's persistent
knowledge map on demand through two gated tools instead:
- list_sessions: every birth session, names + kinds + counts + dates
(graphSessionIndex, Platform's core seam)
- list_propositions(session): what was born there + the session's own
latest understanding level + that evaluation's date
(graphSessionPropositions) — names/levels/dating ONLY, no statement
text (owner ruling: the map's organization is the prober's business,
its prose is not)
Registered via createProberTools when chat-handler passes graphStore
(the graphToolsEnabled gate decided server-side, per-turn fresh); the
prober doctrine gains the map-review nudge at session start.
Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS
are plain records again), semantic-clusters.ts dies with its test, the
embedder dep's doc tells the wave-7 stub story, and ports.ts's
readClusterLabels/readNamingRuns surface stays untouched for Platform's
ledger-port interlude.
848/848 tests, typecheck/lint/build green.
- Right-click any session row: MenuShell context menu, exactly two actions
(owner ruling): Rename and Delete… — MenuShell discipline (placement,
focus, arrow cycling, lh:menu-open announcements), agent-section's
outside-click/Esc contract with [data-lh-menu] carve-outs.
- Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur
commits through the existing plumbing — PUT /api/chats/:id then
lh:chat-renamed. The open chat header now LISTENS for the event too, so
renaming the active session from the sidebar updates the header in place
(it only dispatched before).
- Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure
seam for render pins): "Delete the session only" (preserve-graph — the
graph keeps every contribution) vs "Also remove its graph traces"
(delete-nodes — shared topics survive, only-this-session topics die).
409 turn_in_flight/jobs_running and 503 extraction_winding_down render
the server's line verbatim with a same-mode Try-again.
- Success: the sidebar patches its snapshot instantly, the active session
navigates home (chat unmounts — drafts die with it), and lh:graph-revision
announces the returned revision — the graph view refetches the snapshot
AND costs (deletion is the one writer the status poll can't see).
- ChatDeleteMode type re-exported through the core barrel (the shell's one
needed seam from core/graph-deletion.ts).
Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001:
context menu open/Esc, inline rename (row + API + active header), both
delete modes on scratch sessions, simulated 409 + retry-then-succeed,
active-session navigation home, graph+costs refetch deltas on home.
- lh:settings-saved CustomEvent (the lh:chat-renamed pattern, owner
ruling): every writer (composer save, SetupDialog save, the settings
write seam — one dispatch point carrying the section) notifies after a
successful PUT; every reader re-reads its own seam: ModelControl's
record read, the Agent section's slots/catalog, and chat.tsx's
configured state (clearing slots mid-session now surfaces the setup
CTA without a reload). Self-notifications re-read idempotently.
- MenuShell now announces lh:menu-open/lh:menu-close (still placement-
only); the settings dialog's content column locks (overflow-hidden,
count-based) while any menu is open — wheel-scrolling with a dropdown
down is dead; the re-place-on-scroll fallback stays for un-lockable
surfaces.
- The settings thinking menus' 'Default' noneRow is retired (owner:
the unset state can't re-enter once chunk 2's auto-populate lands; no
unpin is needed).
- Live-caught regression from the MenuShell migration fixed: the
composer's outside-click handler never learned the portaled menu body
isn't a container descendant — menu-row pointerdowns counted as
outside and ATE THE PICK (composer picks have been dead for pointer
users since 221c99d); rows now carve out via [data-lh-menu], the
settings sections' discipline.
- Item 4 (resolved-level display for legacy records) deferred to chunk 2
per owner ruling — the payload omits the level for legacy no-level
records and DEFAULT_THINKING_ORDER was server-only (now in the core
barrel via bd68486, exactly the seam chunk 2 needs).
- existing sessions: a send that lands the 503 models_not_configured envelope
renders as the SETUP FLOW, never the error banner — the record refetch drops
the refused orphan (the gate refuses before the write-ahead persist), the
draft goes home to the composer, and Save auto-replays it (owner ruling);
parseModelsRefusal reads the envelope from the transport's verbatim error
text (exported pure, tested)
- persistent 'Set up your models' CTA above the chat composer while
slotsConfigured is false (server-read seed via app/chat/[id]/page.tsx) —
legacy per-chat overrides may still send with the pill present; the server
gate stays the truth
- SetupDialog prefill + 'needed' marks (owner ruling): pinned tiers open with
their pick (matched in the federated catalog — lapsed keys don't prefill),
unpinned tiers carry the mark; Save still requires all three;
prefillFromSlots exported pure, tested
- refused jobs read as INFO: dispatch card shows a quiet 'needs model setup'
line + 'Set up models' deep-link (ChatActionsContext.openSetup); dock blobs'
activity reads 'needs model setup' instead of 'failed'; real failures keep
the red tone; isModelsRefusal added to the envelope's home module
- agent-section ModelsBody.slots type honest to the nullable contract
Item 1 — no Default in the slot selectors (owner, fix wave): a
"default" model is not a thing in this app. Picking Default used to
delete the slot and re-arm the first-run gate — incoherent. Now the
model menus carry NO clear row at all (a slot holds a pick or reads
"—"), the thinking menus carry a "—" row (menu-none) that clears only
the thinking pin, and the hint line is the roster only — the resolved
factory pin is never named. Master's amendment confirmed: the
factory-pin hint is SUPERSEDED entirely (Platform removes the pins).
Item 2 — the composer control IS the strong slot (owner: "one and the
same"): ModelControl (model-picker.tsx) reads the raw models section
(GET /api/settings) and the federated catalog, and writes
PUT /api/settings/models via composeSlotModel — the same seam the
settings Agent section and SetupDialog use. Props retired: chatId,
settings, onSaved, fallbackModelId, noDefaultModel — the control is
self-sufficient and identical on every surface (composer, home dock,
/chat/new). The per-chat model PUT is gone from the UI; legacy
per-chat overrides keep resolving server-side. StatsBar/DockOverlay
plumbing slimmed accordingly; chat.tsx drops its per-chat settings
state and initialSettings prop.
Item 3 — the composer catalog cache no longer freezes per page load:
fetchCatalog(force) invalidates, and every model-menu open revalidates
in the background (stale-while-revalidate — cached list shows
instantly, the fresh federation swaps in). Owner's repro verified live:
a key saved mid-session widens the menu without a reload.
Item 4 — stale doc comments fixed: model-catalog.ts's "OpenRouter
catalog"/"multi-provider future" (Platform's file — mechanical,
flagged for review) and model-picker's retired "config-pinned"/
per-chat-PUT narrative.
Live-verified on 3001: unpicked slots read "—" everywhere; model
menus have no clear row; the "—" thinking row clears the pin but
keeps the model; composer pick writes strong and preserves cheap/
vision siblings; thinking pill writes strong.thinkingLevel; groq key
added mid-session appears in the open menu without reload. Caught in
live verification: the record fetch was mapping the WHOLE settings
record into the models section (picks never wrote) — fixed to read
body.settings.models.
Gates: typecheck, lint, 807/807, build.
Root cause of the live-test bugs (2026-09-19): pi passes every run's
AbortSignal to tool execute, but toPiTool dropped it — agent.abort()
only landed at the next step boundary, so a long local grep held its
turn hostage until the dev server's memory restart killed the run
mid-tool, freezing the record at its write-ahead state (the 'went
back in time' rollback).
- lh-tool: execute ctx gains signal (parent + timeout combined); every
call races a 30s system-level timeout with a 250ms cooperative grace —
abort kills a stuck tool but lets a poll-the-signal tool deliver its
partial results. Pre-aborted signals never fire listeners: checked
upfront. toolTimeoutMs is the test seam (pdfModelOverride idiom).
- fs-shared: walkFiles returns {timedOut, aborted} instead of throwing —
abort yields partial results.
- grep: the scan phase was unbounded (the walk's 10s deadline only
bounded collection) — same budget now checked per file; find/grep
check the signal and return partials with a notice.
- pending/chat: streamingToolActivity names the ACTUAL running tool —
a part sits input-streaming through its whole execution, so the old
shimmer called minutes-long greps 'Writing a question…'.
Tests: toPiTool guard (forward, abort, timeout, grace, question
bypass, ToolInputError passthrough), walk mid-abort, find/grep
pre-abort + mid-run abort, activity labels. Gates: 617/617.
The browser e2e check on the live dispatch session found the tool-pdf
part rendered NO card: the artifact renderer registry had no pdf entry
(the doc comment literally says "add your renderer here"), and the typed
TeacherUITools union + the chat.tsx job-part enumerations (poll cadence,
dispatch nudge, settle-sync) all missed the new tool kind.
- PdfJobCard in job-card.tsx: the research JobCard idiom, kind pdf —
registered in artifacts/index.ts.
- ui-message.ts: pdf entry in TeacherUITools (fire-and-forget ack output).
- chat.tsx: two mechanical "tool-pdf" additions to the research/demo/visual
enumerations — UI-stream file, flagged for review at merge (same
treatment as the activity-dock type-forced entries).
- demos.test.tsx: live/settled dispatch-card renders + registry pin.
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>
Owner's first live mic test (2026-09-17) drove four changes:
- ONE primary transcription input at all times: while a quiz/ask
question waits, finals land in its field (text mode → 'Your answer',
select modes → 'Your thinking') via a module-level claim registry
(useDictationTarget in dictation.tsx); the composer is the fallback.
Esc in a question field also stops dictation (threaded through
DockOverlay → QuestionDock).
- No interim results: live words were distracting and shifted the meter.
Params now match the pi dictate extension exactly (no interim_results,
endpointing=300, no utterance_end_ms); the meter's width stays fixed.
- Optimistic meter: the click enters the meter view immediately (Stop
pill + meter within one frame, verified at 150ms in the dev shell);
token mint and mic acquisition run concurrently behind it.
- Wide idle button: the stats-bar middle is one full-width affordance —
a quiet bordered 'Dictate' button when idle (failure text replaces the
label), Stop pill + fixed meter while live.
Live-verified in the Electron dev shell: wide button renders on home
(167px) and chat (358px), meter + Stop appear at 150ms after click,
flat 2px bars in silence, clean return to 'Dictate' on stop.