The teacher registry drops the research tool when no search engine is
live (chat-handler's searchAvailable, 2026-09-20) — the settings row
now grays on the SAME /api/search signal, so the row and the actual
registry availability can never disagree (Teacher-stream flag,
2026-09-22, routed by master).
- researchToolUnavailable (pure): research grays exactly when the live
status says unavailable; a loading/failed fetch grays nothing; the
other tools never touch the search port.
- The row: whole-row dim (graph-view graying) + disabled toggle + the
status reason as the hint — the same line the search-engine row
under it speaks from.
Two live-caught fixes while here:
- The reason line refreshed IN the pick handler raced the section PUT
(the hint could contradict the trigger it sits under — caught with a
Brave pin: trigger 'Brave', hint still 'tavily (auto)'). The refresh
now keys on the agent prop's engine pin, so it reads post-write
state.
- The engine menu options render from a map without React keys
(console warning) — keyed.
Live-verified on 3001: no engine key → research dimmed with reason,
siblings untouched; Tavily key saved + section reopened → row alive
('search engine: tavily (auto)'); Brave pinned without its key → both
rows gray with the honest missing-key reason; Automatic restored.
Gates: typecheck, lint, 803/803, build.
Chunk B (owner-designed, model-slots wave) on the merged engine surface
(main 1d66a17):
AGENT SECTION — SLOTS ONLY (owner ruling)
- The six per-role model rows are GONE; three slot rows instead:
Strong (Prober + Teacher), Cheap (five workhorse roles), Vision
(visual maker + PDF reader), roster + resolved-default in every hint.
- Slot dropdowns share the app's menu language over the FEDERATED
catalog (/api/models = keyed providers only, fresh per call — saving
a key in Keys immediately widens the picker); the vision slot
filters to image-capable models (Model.input); the menu's highlight
now matches provider+id (ids can collide across providers).
- Slots write the new models section (PUT /api/settings/models) through
the dialog's write seam; per-role overrides stay schema-only.
- ModelMenuList rows read 'provider · id' — federation makes the
provider bookkeeping-relevant. Usage stamps render provider-qualified
(prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats
bar's turn lines.
SEARCH ENGINE (search wave: no default, owner)
- A row in Tools: Tavily/Brave via the shared-menu look, Automatic =
the pin removed (auto-activation). The live reason line comes from
/api/search; no engine key at all grays the whole row + disables the
trigger (a keyless choice would only block auto-activation).
FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception)
- Chats cannot start until all three slots are picked — no defaults
for 33 providers (slotsConfigured: all three slot models required).
- The graph view's new-session dock (home) shows a 'Set up your models'
CTA while unconfigured; submitting a topic opens the SetupDialog WITH
THE DRAFT PRESERVED; Save stores the models section and the queued
message sends. Same interception on /chat/new sends.
- STAGED BY DEPENDENCY (owner: keys first, models after): no keyed
provider → stage 1 deep-links the settings dialog on Keys (open now
takes a section); a saved key reopens at stage 2 on the next submit.
- Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered,
all three required). Closable without configuring — browsing stays
possible; the gate is the send, not the dialog. Layered Esc: an open
slot menu eats Esc before the dialog.
- Two closure bugs caught live: onDone→start/send captured the stale
pre-save 'configured' (the flow now forces the replayed send); menu-Esc
used to close the whole dialog.
Settings-dialog provider: open(section?) lands on a section (stage 1
deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR
replacing role rows, slotsConfigured/setupStage first-run gates,
provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
The owner called the old section messy; the redesign is theirs:
- MINIMAL TABLE: provider label left, masked status right (…tail ·
chars · env), description hints GONE (they took too much space),
the 'Get a key ↗' link a tiny muted guest on curated rows. Rows
read as clean key-value pairs.
- ENV-OWNED ROWS ARE READ-ONLY (owner ruling): a real environment
variable owns the value — the row dims (graph-view graying) and no
paste field renders, because a paste box on a key the UI cannot
make live would mislead. Clicking does nothing.
- OPENROUTER UN-PINNED (owner ruling): one provider among 33 — a row
only when stored, otherwise it's an option in the Add dropdown like
everyone else. No synthesis, no lifeline description.
- ROW INTERACTION (owner choice): clicking a writable row unfolds a
paste field + Save (+ Delete for a file-sourced value) beneath it;
success folds it back to rest; Esc folds without closing the dialog
(claimed at document CAPTURE phase — the only stopPropagation that
outruns both React handlers and the dialog's window listener; the
React-level attempt leaked and closed the dialog, caught live).
- ADD-PROVIDER = the table's last row, in the app's shared dropdown
language: new ProviderMenuList (fuzzy search + arrow-walk + Enter
over the 33 pi-ai providers, label + envVar rows) in the shared
model-menu.tsx, portaled and fixed-positioned like the role menus.
Picking one unfolds a paste row; Save stores, Cancel/Esc folds.
- Tools & services (Deepgram + the Google pair) keep their own group,
same table look, same unfold interaction.
- Unknown auth.json vars stay read-only dimmed rows.
Tests: classifyKeys re-pinned (openrouter rides the normal provider
classification; addable drops stored keys), keyStatusLine minimal
('not set' for absent, ' · env' marker for environment-owned). Gates:
typecheck, lint, 759/759, build.
Live-verified: add→save→replace→delete round-trips, fold-on-save,
Esc layering both flows, env-owned rows read-only, addable count
31→30→31.
Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
auth is the Bearer header, not the legacy body api_key; Brave is
X-Subscription-Token) + engine resolution per owner decisions: a key
ALONE activates its engine (no engine choice needed), explicit
agent.search.engine pins the preference (both keys: Tavily first),
a pinned engine with a missing key is honestly unavailable — no
silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
(CSE closed to new customers, dies Jan 2027 — stored values become
preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
(length guard) join. UI keys-section TOOL_IDS now derives from the
catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
the settings gray-out; researcher-disable semantics ride the same
signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
web.ts's WebSearchResult is now the core SearchHit (re-exported).
Owner-approved design (2026-09-20): a Keys section right after General,
two groups over the merged auth API (GET /api/keys masked statuses,
PUT/DELETE /api/keys/:id, immediate effect, shape-gated 400s):
- Model providers: OpenRouter pinned as an always-visible row (the
app's lifeline — synthesized when absent so first boot shows it),
stored provider keys render as they appear, and an Add-provider
dropdown lists every pi-ai provider that takes an API key. The
dropdown reads GET /api/keys' optional providers array — the
Platform interlude (master-routed) derives it from pi-ai's own
env-key table; until it lands the group shows a pending note
(rolePins pattern — absent field, no drift, no guessed routes).
- Tools & services: the curated rows — Deepgram (dictation) and the
Google pair (search key + CSE ID) — with description hints and docs
links (external links route through the Electron shell to the real
browser).
- Rows: always-visible paste inputs (owner choice; no reveal —
plaintext never returns from the store), masked status line
(…tail · chars · environment|saved), the shadowedByEnv explanation
when a real env var owns the name, per-row inline 400 messages, Save
on Enter/button, Delete when present (env-owned keys honestly keep
their env status after a file delete).
- Unknown auth.json vars surface as read-only dimmed rows — preserved
by the store, not editable through the curated surface.
classifyKeys/keyStatusLine pure exports drive the two-group
projection; tests: SSR loading state, first-boot synthesis, provider
row classification, addable filtering, unknown-var read-only status
line formatting. Nav grows to six sections (Keys second).
Live-verified: nav order, structure, bad-paste 400s inline, save
round-trip with env-shadow explanation, delete round-trip, env-owned
OpenRouter status stability. Gates: typecheck, lint, 748/748, build.
- Toggle (owner bug report): the ON disc sat flush against the pill's
right edge — translate-x-4.5 left only a 1px sliver; now translate-x-4
for a 2px inner inset both states (off: 3px left inset; on: 3px right
inset — measured live, symmetric).
- Toggle colors (owner ask): ON is a gray track (muted-foreground) with
a WHITE disc (foreground) — the full-white track was too loud; OFF
stays the muted track with the gray disc.
- Nav spacing (owner ask): gap-1 between the section nav buttons so the
hover highlights no longer touch.
- Role dropdowns (owner ask): the menus were clipped by the dialog's
overflow frame — they now portal to document.body with fixed
positioning against their trigger (AnchoredMenu), right-aligned,
repositioned on scroll/resize, so they flow over the whole screen
outside the settings dialog. Verified: a bottom-row menu extends
137px past the dialog's bottom edge; search + pick + Default-clear
work through the portal; Esc and inside-dialog clicks close the menu
but not the dialog.
- Thinking trigger (owner ask): fixed w-20 — the width the Default
state needs — so picking levels no longer shifts the row; the model
trigger takes the same width as a minimum (its values vary in length).
- Role hints (owner ask): ALWAYS visible and naming the actual default —
'Default: <model>'. The resolved per-role pins are not client-side
yet: master routed rolePins to the Platform stream (GET /api/models
extension, standalone commit); until that merge the rows read the
'config pin' fallback and light up on rebase. rolePins also lets the
thinking list use the pinned model's real supportedLevels when it's
in the catalog.
Also: staging repair commit 270db53 — the round-1 dialog rewiring
(sidebar row, chord, redirect, keyframes) was listed in 27d4f33's
message but never staged; committed as catch-up.
Live-verified on the worktree dev server. Gates: typecheck, lint,
701/701.
Round 1 (27d4f33) listed sidebar.tsx, keyboard-shortcuts.tsx,
app/settings/page.tsx and the globals.css keyframes in its message but
they were never staged. This commits exactly that content: the sidebar
Settings row as a button calling the dialog provider, the mod+, chord
opening the dialog, /settings redirecting to /chat/new, and the
settings-pop keyframes (reduced-motion aware). No new changes —
catch-up staging only. (next-env.d.ts churn left untracked-in-commit as
dev-server noise.)
- Toggle knob (bug report): the knob span had no left anchor, so it sat
at its inline static position — right-edge in off, overflowing in on.
Now left-0 + the intended 2px/18px translate; verified live: 3px from
left when off, 1px right inset both states.
- Role model/thinking dropdowns (owner ask): the EXACT chat model
selector dropdown, not a lookalike — the fuzzy-search + arrow-walk +
Enter machine moved from model-picker.tsx into the shared
components/settings/model-menu.tsx (ModelMenuList/ThinkingMenuList),
ModelControl now renders the same bodies, and the agent roles open
them downward with a pinned Default row (clears the override). The
collapsed trigger keeps the thin select look (owner: 'the collapsed
look is good'). Esc claims and closes only the menu (the dialog
stays); a backdrop click is left to the dialog's own close.
- Sub-groups (owner ask): SettingsGroup — Models / Tools / System
prompts inside Agent, each with a separator + quiet label; section
headings more pronounced (text-sm, full-foreground).
- Graying (owner ask): graph-view render-panel convention — the WHOLE
row dims (opacity-40) and the control locks; the row owns the dim so
no double-opacity. Applied to the graph ladder, the mode row, and
prompt rows without an override file. The 'needs X on' prose is gone.
- Hints pruned (owner ask): mechanics-explaining lines removed — the
ladder mention, 'App-level behavior', 'Applied live as CSS
variables', per-row explanations. Only the keyboard storage note
stays, reworded 'Stored on this device' (desktop app — localStorage
is the Chromium profile in Application Support, not 'this browser').
Live-verified: knob geometry both states + PUT round-trip, menu
search/pick/clear through the record, thinking set/clear, Esc layering,
ladder dim 0.4 with siblings full, pruned phrases absent. Gates:
typecheck, lint, 701/701.
The dialog's content wrapper added px-6 py-5 ON TOP of SettingsSection's
own padding (48px horizontal, 81px top — measured live). The wrapper is
now bare; the section component owns its rhythm, and the loading/error
states carry their own padding. Section box now spans the scroll column
(61px from the dialog top = the 60px header band), text inset 24px.
Gates: typecheck, lint, 701/701.
Owner decisions this wave: a centered modal over everything (not a page
— a settings column in the main area next to the session list would read
as two sidebars), left section nav in the dialog, modal-only (the
/settings URL redirects to /chat/new), light mode deferred to its own
wave (the mode control stays honestly disabled). An actual OS window is
an Electron-round feature — the sections are surface-agnostic (API +
localStorage only), so the shell re-mounts there with near-zero churn.
- components/settings/settings-dialog.tsx: SettingsDialogProvider at
the AppShell level (portal-rendered dialog, z-50 over the dock/graph
chrome). ~920×620 centered frame, 60px header with the quiet CMU
title + ✕, w-44 text-only nav (session-list mono language, active =
bg-accent, no icons), one scrolling content column mounting one
section at a time. Escape / backdrop / ✕ all close. 150ms fade+2%
settle scale (settings-pop), reduced-motion honored. The record +
single write seam moved here from the thin page: one record instance
per dialog, section switches never refetch, appearance writes still
restyle the app LIVE behind the dialog.
- Entry points rewired: the sidebar Settings row is a button calling
open(); the mod+, chord opens the dialog instead of routing.
- app/settings/page.tsx: redirect to /chat/new (nothing links into a
dead page).
- SettingsDialogBody exported without the portal — the SSR-testable
frame (portals render nothing under renderToStaticMarkup).
Live-verified: exact centering, all three close paths, chord open,
appearance prose-size restyles the chat behind the open dialog
(16→20px), section switching retains the record, keyboard section
renders all 21 rows, no console errors. Gates: typecheck, lint,
701/701 (5 new dialog tests), build.
Owner report: the scrollbar pressed against the slider content. Panel
w-64 -> w-72 and the inner scroll column gains pr-2.5, so the spacing
is concentric: content -> 10px -> scrollbar -> 10px -> panel edge. The
scrollbar itself is NOT moved — it keeps its distance from the panel's
right border; the panel simply widens to give it air.
- Corner rounding back (owner, 2026-09-20): the four controls return to
rounded-xl — 16px in this theme, identical to the composer shell's
rounded-2xl (verified computed-style equal). Inner elements (search
input, import button, cost hover) stay concentric: 16 - 4px padding
= rounded-lg (12px), the composer's own concentric math.
- Equal spacing: the cluster pins 8px from BOTH edges (top-2 + right-2),
still flush with the sidebar toggle's header band (top 8px).
- Stacking: the actions row is now relative + z-20 — an open settings or
coverage dropdown paints over the cost number below it (owner report:
it layered ON TOP of the panel, still hoverable/clickable). The row is
a stacking context inside the column, so its dropdowns cover the
number and clicks land on the panel; the number resurfaces untouched
when the panel closes (verified with elementFromPoint).
- Rectangular look (owner, 2026-09-20): the four top-row controls drop
rounded-xl (16px theme) to rounded-sm (4px) — gear, coverage, search
container + input, import container + button; the cost number's hover
highlight joins the family. Dropdown panels keep their softer cards.
Owner report: the controls folded onto two rows (search+coverage /
import+gear) — the 2026-09-17 flex-wrap kicking in, plus a real bug: my
previous wave moved max-w-[calc(100%-9rem)] onto the shrink-wrapped
column wrapper, where the percentage reference broke and the wrap fired
far too early. Owner wants the controls to read as header content.
- The width cap moves to the absolute column itself (100% resolves
against the full-bleed parent, as before): max-w-[calc(100%-5.5rem)].
- One row, NEVER wraps (owner rejected the two-row fold): search is the
flexible element (w-56 + min-w-0, shrinks under the cap); the other
controls are shrink-0.
- All controls slimmed into the header band: top-2 (8px) matching the
header's py-2, 36px tall matching the toggle's h-9 — size-9 buttons,
h-7 import button, px-1 py-[3px] containers (border included = 36).
- Cost number + pipeline pills stay below the band, unchanged.
Owner-directed placement wave (2026-09-20): the bottom-left stack
overflowed the composer in narrow windows, and the pill chrome was
too prominent for a passive number.
- The cost surface is now JUST a number (cumulative dollars) top
right, below the actions row — no pill container, hover-highlight
kept, dropdown breakdown kept, viewport-capped + scrollable.
- Same dismiss pattern as the settings gear: outside click or Escape
closes (shared usePopoverDismiss; two dropdowns never open at once).
- Running-pipeline pills moved under the cost number in the same
top-right column — one right-aligned column grows downward from the
top, so no window width can push chrome over the composer.
- Cluster namer bills the money channel now: per-step usage + model
captured via onStep, persisted as NamingRunUsage records to
data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted
runs persist too — money spent either way). callUsage promoted to
extraction.ts module scope for reuse.
- core/cost-summary.ts: the pure fold behind the pill — chat runs,
notes-source runs, and naming runs into stage totals (extraction /
audit / naming) + newest-first recent runs. Dollars INFERRED read-time
via prices.ts, never stored; unpriced models excluded-but-counted
(render '—', never a silent zero); tokens always sum.
- GET /api/graph/costs (src/server graphCosts composition, thin route).
- Graph view: bottom-left cost pill (cumulative dollars headline) +
expandable breakdown panel (stage rows with tokens secondary, recent
runs with time/title/merged/cost); fetched on mount, refetched when a
pipeline run finishes; merged into one bottom-left stack with the
running-pipeline pills.
- Tests: pure-fold suite, naming capture wiring (full importNotes ->
naming path), store idempotency + strict reads.
The validation surface for the settings foundation — deliberately thin,
real read/write against the delta record, polish is a later wave.
Mount (owner decisions): dedicated page, single scroll; entry = sidebar
bottom row + ⌘/Ctrl+, (open-settings in the registry); chat defaults NOT
shown (they live on /chat/new); mode dark-only (light palette = design
wave); final tool list = research/demo/visual/pdf (master-adjudicated).
Sections (PUT /api/settings/[section], replace-whole per section):
- General: auto-update toggle.
- Agent: six background-role model+thinking selects (Default = config
pin; a stored id stays VISIBLE even when the catalog hasn't loaded or
dropped it — a select showing Default over a live override would lie),
the four tool toggles, prompt override switches (file-gated) with
source/override text editors over the segment routes.
- Appearance: applied LIVE as runtime CSS variables by an
AppearanceProvider at the root layout — proseFontSize → the one
reading-size var, fontFamily → --font-prose, background → --background
+ the derived raised/accent/edge palette (dark curve, today's exact
spacing: +8/+22/+47 per channel, clamped), sessionNameSerif →
--font-title (titles flip with ZERO chat.tsx edits), headingWeight →
new --lh-heading-weight token, bold/italic colors → the lh-ink vars.
Mode renders honestly DISABLED.
- Graph: the ladder grays a rung when any ANCESTOR is effectively off
(stored values persist, resolve inert — switch shows stored, graying
shows effective).
- Keyboard: view/rebind/reset over the registry; overrides persist in
lh:keymap and apply INSTANTLY (dispatcher reads per keydown); bare
modifier presses are ignored while armed; displayOnly actions list but
never rebind.
First-boot fix: {settings: null} is no-record, not fetch-failure.
CROSS-LANE (flagged for master): src/core/keyboard.ts gained the
open-settings action/binding and exports saveOverrides/loadOverrides
(one-liners; no existing pins); app/layout.tsx mounts the provider.
Live-verified: structure, record writes (general/tools/graph/appearance),
prose-size applying live (16→20px), rebind + chord navigation from
another page, ladder graying, no console errors. Gates: typecheck,
lint, 689/689 (28 new), 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.
Live-smoke follow-up the user approved (2026-09-19): the GEB read showed
"78 pages consulted" when the reader genuinely READ ~12 - pdf_search hit
snippets inflated the count. PdfResult now carries both:
- pagesRead: extract/render output entered the loop - the honest count
the badge and brief footer show ("read N pages", "Pages read: ...").
- pagesConsulted: every page whose content entered the loop, search-hit
context lines included - unchanged, still the citation audit set (the
reader's instructions still say "Pages consulted: N, N, ..." and the
footer still lists it when it exceeds the read set).
The unread-finish gate deliberately stays on CONSULTED: search hits are
genuine consultation (anti-fabrication duty), so a search-only brief is
honest and must deliver - pagesRead is a reporting refinement, not a
delivery gate. Pinned by the search-only faux test (read [], consulted
[1], delivered).
UI tolerates pre-split persisted records via ?? fallbacks (old steers
lack pagesRead).
User pivot (2026-09-19): no attachments UI at all. Pasting paths is native
for technical users and alien for everyone else - so the agent gets the
tools to find what the learner merely NAMES. The learner communicates in
messages; the agent locates and reads. Zero cross-stream seams: paths
already live in tool parts, so the sources "list" is the transcript.
- ls/find/grep: pi-verbatim schemas, descriptions, and output formats
(fd's glob semantics, rg's path:line: match format, pi's truncation
notices), ported to pure Node - no binaries, no shell-out, read-only by
construction. Deltas from pi: home-anchored resolution (the harness has
no cwd), .gitignore replaced by hard noise exclusions
(node_modules/.git/Library/.Trash), 10s walk timeout with partial results.
- read: description swapped to pi-verbatim (doctrine moved to the prompt);
relative paths now resolve against the learner's home like the rest.
- prompts/shared/05-learner-files.md: one minimal doctrine fragment
composed into BOTH agents (composeInstructions grew a shared/ dir,
before the agent's own fragments). teacher/30-jobs.md keeps only the
pdf-dispatch addendum.
- ToolCallCard: the generic quiet tool-call card the user specced -
collapsed one-liner (tool name + headline argument), expandable to full
input/output, errors red, image payloads never render. Registered for
read/ls/find/grep.
- ui-message.ts: TeacherUITools entries for the four tools (mechanical).
- tests: fs-tools suite (glob semantics, pi formats, exclusions, limits),
registry/card renders, shared-fragment composition pins, tool-registry
key updates, read re-anchor.
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.
Learner source files, on the teacher's own terms (nothing auto-injects):
- read tool (src/agent/tools/read.ts): pi-read port — path/offset/limit,
2000-line/50KB truncation with use-offset continuation notices, image
files (jpg/png/gif/webp/bmp) returned as native image content parts
(new LhToolImageResult escape hatch in lh-tool.ts). Always-on in both
the prober and teacher registries (user-decided 2026-09).
- pdf-reader sub-agent (JobKind 'pdf', toggleable, ack-and-steer): triage
via pdf_info (pdfjs text layer: per-page text/image/math-density),
targeted pdf_extract, regex pdf_search (hits count as consultation),
and pdf_render — pages painted by pdfjs onto a canvas shim in headless
Chrome (the pdfium viewer doesn't paint headless), screenshot fed back
image-in-the-loop (visual-maker idiom, providerSafePng applied). The
unread-finish gate bounces a zero-consultation brief once. Teacher gets
a text brief with page citations only; renders never leave the loop.
- MODELS.pdfReader = qwen/qwen3.8-flash (user override 2026-09-19: the
look-at-renders workload wants the vision-proven model; catalog listed
glm-5.3-flash as image-capable but qwen is the known-good daily driver).
- UI: PdfCompleteBadge + PdfBriefCard (steer-card, job-card), dispatch
card + activity-dock kind entries (type-forced, flagged for UI review).
- Prompts: 'Learner source files' section in teacher/30-jobs.md; read
line in prober/10-probe-doctrine.md. Doctrine rides tool descriptions
per the self-contained-tools principle.
Owner reframe (2026-09-18): the model + thinking + context controls on
the home graph view and /chat/new are not a draft overlay on a baked-in
default — they ARE the settings page for the default model, and the
user's last choice IS the default. BYOK first boot has no keys and
possibly no OpenRouter, so there is NO default to show: the controls
render 'no model' ('—') until the first pick.
The flash was the deeper symptom: the surfaces rendered from client
state that started empty and fetched truth afterward, so every visit
flashed the config pin (GLM/low — a dev artifact) for a few frames.
Fix at the root: both pages are now force-dynamic server components
that read the record (readDefaultSettings → initialSettings) and hand
it to the client — the first frame shows truth. Dynamic routes aren't
cached client-side (staleTimes.dynamic = 0), so every navigation
re-reads the record; the useDefaultSettings hook keeps only save-side
state (optimistic set + PUT). The GET-on-mount and its touch-guard are
gone.
ModelControl gains noDefaultModel (the default surfaces only): unset
settings render no model/level, never the catalog pin; the thinking
menu no-ops until a model exists. Chat surfaces keep the catalog
fallback — the engine runs the config pin when a session record has no
override, so the control keeps showing what will actually run.
Renamed draft→default throughout: data/default-settings.json,
GET/PUT /api/settings/defaults, useDefaultSettings. New sessions
still inherit the default on creation exactly as before.
Live-verified: first-boot 'no model' with zero flash, picker +
Shift+Tab cycle, values held across session→/chat/new→home roundtrips
and a reload (10×100ms samples, never the old default), zero console
errors.
Both new-session pages (the home graph view's topic dock and /chat/new)
held their model + thinking + knowledge-context choices in plain
useState(undefined) — every navigation reset them to config defaults.
Now both surfaces read/write ONE server-side record
(data/draft-settings.json via GET/PUT /api/settings/draft, the
render-settings pattern): whatever the learner last left the controls on
is what every later visit shows. useDraftSettings loads on mount with a
touch-guard (a slow GET never clobbers a fast choice), saves
optimistically. Session creation still carries the choice into the new
chat exactly as before; per-session settings are untouched.
The PUT validation is the exact per-chat contract — parseSettingsBody
extracted verbatim from app/api/chats/[id]/settings and shared by both
routes (unknown model/level → 400 with the supported list, never a
silent clamp). 400s on garbage, {} = the clear-overrides write.
Live-verified: Cmd+K+Enter model pick, Shift+Tab thinking cycle, context
slider — all held across home → session → home → /chat/new, and carried
into a created session's record.
The walked row's ring-1 is a box-shadow painted OUTSIDE the border box;
the list's overflow-y-auto makes overflow-x clip at the padding box too
(CSS: a non-visible axis forces the other to auto), so the 1px halo was
shaved on both sides. Replaced with the quiz rows' bg-accent/40 filled
tint and added px-1 to the list so the highlight never touches the
edges (owner round 5).
Owner polish pair before the settings milestone:
- Model menu: arrows walk the filtered results (clamped, no wrap) while
focus stays in the search field; Enter picks the walked row or, with no
walk yet, the first result; typing resets the walk; click and Enter
share one pickModel path; walked row gets a muted ring + scrollIntoView;
cheat-sheet display entries added.
- Graph: window resizes re-anchor the camera so the world point under the
pre-resize viewport center stays under the new center — the graph stays
visually still while scaling (was top-left pinned). viewW/H tracked in
syncSize; re-anchor applied through the zoom behavior (persistence and
card-dismiss unchanged); kFit refresh as before. Pixel-verified: 7/7
center samples identical across a 300x200 shrink.
Tab/arrow focus on quiz/question option buttons and the don't-know row
showed Chromium's default focus ring — the abandoned look. outline-none
on the row buttons keeps the walk ring (bg-accent/40) as the only focus
indicator, so the appearance is identical before and after Tab.
Owner live-test rounds on waves A/B:
- Alt+arrows no longer ALSO walk the question rows (dock ignores
alt/meta/ctrl combos; session cycling keeps them exclusively)
- question walk clamps at the ends — no wrap (faster to navigate)
- plain Tab toggles focus between the thinking field and the answer
rows (walked row by default); focus follows the walk while in the
rows; Shift+Tab stays the global thinking-level cycle
- Esc in the sessions search blurs back to normal (claims the key so
the window Esc chain does not fire)
- cheat sheet gains the Tab-in-question display entry
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>
The settings panel (16 sliders ≈ 900px) overflowed past the window
bottom at short heights and stretched the whole page scrollable. The
coverage dropdown's pattern is the standard for top-anchored popups
overflowing viewport bounds: max-h-[60vh] keeps a floor of free space
above the window bottom at any height, and the inner column gets
overflow-y-auto (which also zeroes the flex item's automatic
min-height so it actually shrinks) — the panel scrolls internally, the
page never scrolls.
Live-verified in the dev shell at emulated 640px and 1000px viewports:
panel caps at exactly 60vh (384px @ 640h, 192px bottom gap), inner
scroll reaches the physics section (412px travel), docScrollable false
at both heights.
Owner's second live test:
- DEFERRED transcript: finals accumulate in a buffer during the run and
flush into the primary input once, when dictation ends (Stop →
CloseStream → Deepgram's last finals → socket close → one joined
append; the 3s safety net covers a missing close). Nothing textual
renders while recording — the meter is the only live feedback. Live
output may return as a toggleable setting at the settings milestone;
deferred is the default behavior.
- ADAPTIVE meter: cell count derives from the measured strip width (4px
pitch, 8-48 cells, ResizeObserver; cells drop their minimum width) so
narrow rows beside the knowledge-context slider fit — verified in the
dev shell: draft 20 cells/83px, chat 48 cells/273px, no overlap.
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.
Two live-verified Deepgram facts from the first real mic-test round:
- /v1/auth/grant returns { access_token }, not { token } — the route now
parses the right field.
- Minted JWTs authenticate over the WebSocket via the "bearer"
subprotocol; ["token", jwt] is rejected at the handshake (token is
for raw API keys only, as in the pi dictate extension).
Also documents the env-precedence gotcha in docs/research/stt.md: a
DEEPGRAM_API_KEY exported in the shell (e.g. .zshrc) silently beats
.env.local in next dev and the desktop authEnv() seam.
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.
The sidebar no longer squeezes the page when the window narrows; the
page itself never goes below a comfortable floor (owner decisions,
2026-09-17: 640px min, widen-window on toggle, auto-restore):
- minWidth 640 / minHeight 600 on the BrowserWindow (macOS enforces)
- Below SIDEBAR_AUTOCLOSE (928 = 640 + 288 sidebar) the sidebar
auto-closes, and auto-RESTORES when the window widens past it again —
both fall out of one expression: rendered = pref && !narrow, where
pref is the persisted user choice. Manual closes persist and stay
closed; auto-close never touches the preference.
- Opening the sidebar while narrow asks the shell to widen the window
to 928 first (new lh:expand-window IPC, preload.cjs contextBridge —
the renderer's first and only Electron bridge): the toggle is never a
dead button, and the layout never has to survive narrow + sidebar.
In the plain browser there is no suppression and no widening — a
narrow tab opens the sidebar if the user says so.
- Graph chrome row wraps: max-w-[calc(100%-9rem)] + flex-wrap keeps the
row clear of the header toggle cluster and stacks it onto extra lines
below ~640px instead of overflowing left.
State machine verified in the Electron dev shell across all six
transitions (auto-close, auto-restore, manual-close-persists,
reopen-widens, browser fallback, wrap at 640/500/360).
The home header carried -webkit-app-region: drag after <main> in the
tree, re-adding drag over the graph chrome row's no-drag carve-out —
Electron's mask applies carve-outs only to regions added EARLIER in the
DOM walk, so the gear/search/coverage/import buttons dragged the window.
Fix: a dedicated 52px drag-mask div placed FIRST in the home page DOM;
every carved control (chrome row, node detail card, toggle) comes later
and subtracts correctly. The header loses its app-region entirely — the
mask routes the pixels, the header stays a pure pointer-events container.
Retires the .lh-float-drag CSS class.
Verified live in the Electron dev shell by the owner: chrome row,
sidebar close button, logo, and window drag all work.
Traffic lights (owner report: 'way too low, not aligned with the phase pill'):
measured in the running app — circles centered at 30.75pt vs the pill box's
29.25pt (band 0-59.5, pill box 19-39.5, pill ink 28.75; uppercase glyphs sit
high). trafficLightPosition y 24 -> 22.5 lands the circle centers at 29.25,
exactly on the pill. Owner confirmed aligned after this change.
Rename escape hitbox (owner report): entering rename was already width-of-
title only, but the editing input spanned flex-1 so clicking the rest of the
bar stayed inside the input. The input is now shrink-to-fit
([field-sizing:content], min 8ch / max 70%) and any mousedown outside the
input blurs it, running the existing onBlur commit path; the header drops
lh-drag while renaming so the drag region cannot eat the escape click.
Verified: click-away commits (exactly one PUT), Escape discards, Enter
commits.
Phase pill is pure decoration (owner report: 'should not be interactable
in any way'): no tooltip, pointer-events-none, select-none — part of the
drag surface instead of a carved-out target.
Graph chrome was unclickable in Electron (owner report): home.tsx's
floating drag strip underlays the z-10 controls row, and Electron's
app-region mask ignores z-index, so the region ate their clicks (plain
browser unaffected — DOM hit-testing is fine). lh-no-drag carve-outs on
the chrome row and the node detail card (it is placed imperatively
anywhere, including the top strip).
LhMark home link now mirrors the ghost toggle: same size-9 chip geometry
and hover:bg-accent treatment.
The system title bar disappears (titleBarStyle hiddenInset +
trafficLightPosition {16,24}): the app's own 60px header bands become the
window's drag surface, and the traffic lights float over the sidebar's
mark band, vertically centered on the same line as the chat header's
phase pill (pixel-verified: circle centers y 24–36.5pt, pill box center
30pt).
- electron/main.cjs: hiddenInset chrome; dev shell (LH_DEV_URL) gets its
own userData (-dev suffix) so it never fights the packaged app's
single-instance lock — verified: the lock silently killed the dev
shell while the packaged app was open (that also proved the lock works
across separately-launched builds).
- app/globals.css: .lh-electron-gated utilities — drag/no-drag bands,
92px traffic-light gutter (.lh-lights-gutter / .lh-closed-toggle:
lights end ~75pt + the lights' own 16px edge inset as the gap),
home's floating strip becomes a drag surface only in the shell.
Everything is inert in the plain browser.
- sidebar.tsx: SidebarProvider sets <html class=lh-electron> post-mount
(UA detection; no hydration mismatch, absent in browser); LH wordmark
text replaced by the owner's mark (components/lh-mark.tsx, inlined
icon-no-padding.svg, 24px, home link kept); SidebarToggle gains the
92px margin when closed so it never slides under the static lights.
- chat.tsx: the rename hit-box shrinks to the title text itself —
flex-1 spanned the whole bar, so every click started a rename and the
window lost its grip. Shrink-to-fit + a flex-1 drag spacer (chips stay
right) + hover:bg-accent affordance (clear when hover renames vs
drags; -mx/px cancel for zero shift). The renaming input keeps flex-1
for edit room.
- home.tsx / new-session.tsx / phase-divider.tsx: drag bands + carve-outs.
Live-verified in the dev shell (pixel analysis) and headless browser:
lights/pill/mark centered, 17pt lights→mark gap, rename click→select-all
→Escape roundtrip, browser layout untouched.
Gates: typecheck, eslint, 500/500 tests, build.
Rename (owner ask): clicking the header title opens edit mode with the
whole title selected — typing replaces, arrows move inside. Enter or a
click away commits (PUT /api/chats/:id); Escape and an empty commit
discard. renameChat joins the ChatStore port; like saveSettings it is
NOT conversation activity — updatedAt stays put so sidebar order never
churns on a rename. The sidebar patch is instant: the header dispatches
lh:chat-renamed and SessionsSidebar folds it into its snapshot (the 2.5s
poll is only a backstop). Sidebar right-click rename is DELIBERATELY
deferred: it needs a real context-menu system, which gets its own pass
later (owner decision, 2026-09-17).
Graph chrome:
- The extraction-coverage button moves between search and import; the
gear keeps the far-right corner. Coverage still renders on an EMPTY
graph (that is exactly when it matters).
- The no-match search note no longer shifts its siblings: the chrome
row is items-start (every button is 44px tall, so top alignment pins
the row while the search container grows downward).
- Popovers dismiss properly: pointerdown anywhere outside the control's
root (button + panel) closes it, and so does Escape. Two panels can
never be open at once — the sibling chrome buttons are outside the open
root, so pointerdown closes the first before the click opens the
second. New shared hook components/popover-dismiss.ts, wired into
both the settings gear and the coverage control.
Gates: typecheck, eslint, 498/498 (+2 rename store tests), build.
Live-verified: select-all/focus/Enter/Escape/empty/blur round-trip,
updatedAt untouched, sidebar instant patch; chrome order + frozen
positions under a no-match query; popover close via canvas, search
input, Esc, mutual exclusion, toggle intact.
11 commits on fbeca36: minimal-color rounds 1-2 (phase color retired,
the braille spinner is the one working indicator; the chip is label-only),
typography rounds 1-5 (three-font rule enforced — CMU titles, Source Sans
session titles, JetBrains mono; snapped type ladder in globals.css with
the xxs rung merged into xs; lh-input caret at the font-metric floor;
session titles at prose size, message counts and active-row weight dropped,
ISO dates), header rounds 6/6b/6c/6d (60px top bar aligned to the sidebar
LH band; the open-sidebar toggle matches the close glyph at size-4 both
ways; SidebarToggle always-mounted with a lockstep animated slot — no more
one-frame title jump; title at text-3xl).
Semantic change folded in: `busy` in the chats list now means exactly "a
teach turn is in flight" — background jobs no longer spin the session row
(the sidebar's Background agents section is their channel).
Merge fallout fixed in the merge: components/graph-coverage.tsx (main's
coverage panel) held the last three text-xxs uses; with the rung deleted
they silently lost their size — mechanically swapped to text-xs, matching
lh-ui's own swap in graph-context-marker.tsx.
No file overlap with main's waves since fbeca36 (extraction coverage,
lh-graph merge, world-scale bake) — clean auto-merge. Gates: typecheck,
eslint, 496/496 tests, build. Live-verified on the dev server: 60px
header with 32px CMU title, label-only chip, snapped ladder active
(--lh-prose-size 16px, no --text-xxs rule anywhere), sidebar rows with
ISO dates, animated sidebar toggle with no header jump.
When the sidebar toggled, the header's toggle mounted/unmounted in a
single frame, so its 48px footprint (button + gap) popped and dragged
the title and chips sideways while the sidebar was still mid-slide —
'kind of masked by the motion, but very much noticeable.'
The toggle is now ALWAYS MOUNTED, owning a slot whose width+margin
animate in lockstep with the sidebar's own width transition (same
duration-200, same easing, both gated on mount so no load flash):
- when open the slot is exactly ZERO (w-0 with -mr-3 cancelling the
header's gap-3) — resting layouts are pixel-identical to the old
conditional render;
- the icon fades with the slot (opacity in the same 200ms) and is
clipped by it as the slot collapses — no popping in either direction;
hidden state is pointer-events-none, aria-hidden, tabIndex -1;
- the mount-gating rule grows to transition-[width,margin,opacity].
rAF-verified both directions: the title's x-path is monotone from
frame one (old bug teleported 48px in one frame; the margin blip
caught on the way is also gone), icon opacity eases 0.12→1 over the
slide. Gates: typecheck, lint, 469/469.
The header toggle's PanelLeftOpenIcon was size-5 while the sidebar's
close icon was size-4 — the button visibly grew when the sidebar closed.
The small look is the standard: both glyphs are size-4 in identical
size-9 boxes now.
Live: open glyph 16px, close glyph 16px, button boxes 36px both states.
Gates: typecheck, lint, 469/469.
h-10 → h-15 (60px) in both the chat header and the /chat/new draft
header. 60px is an alignment, not just a size: it equals the sidebar's
LH wordmark band, so the header's border-b continues that band's bottom
edge as one horizontal line across the app — the sidebar's 'New session'
row begins exactly where the chat column's chrome ends.
Live: header border-b at y=60, wordmark band bottom at y=60. Gates:
typecheck, lint, 469/469.
Three declutter moves, all trivially reversible:
- The meta line drops the message count (idle meta is now just how long
ago). The count informed no decision — clutter. messageCount still
rides the API payload, so this is a one-line flip in sessionMeta if
it's ever wanted.
- The active row is marked by the background alone: weight was a second
channel doing the same job, and the flip made selected titles jump.
- Dates beyond the week render ISO (YYYY-MM-DD, zero-padded) instead of
M/D/YYYY — the US convention swapped for the international standard:
unambiguous in any locale, sorts lexically. A user-locale setting can
come later — it's a format-function swap.
Also dropped the row's vestigial text-sm: both lines carry explicit
sizes now (16px title, 11px meta).
Live: active weight 400 + bg-accent, metas '17m ago' /
'Waiting for your answer · 1d ago', no counts. Gates: typecheck, lint,
469/469.