Commit Graph
100 Commits
Author SHA1 Message Date
amosblomqvist ff3700a2d0 Polish: session names take 900 weight serif-off; floating controls match graph chrome
- .font-title now requests font-weight: 900 — with the serif toggle OFF,
  the prose font is a VARIABLE font and was rendering titles at 400, a
  whisper next to CMU's bold-only face; 900 lands directly on Source
  Sans 3 and CMU's single 700 face snaps to nearest, unchanged. Same
  rendering with serif on, real weight with it off.
- The model + thinking pills match the graph view's floating top-right
  chrome (import notes / gear / coverage): border bg-card/85 shadow-lg
  backdrop-blur — the graph no longer bleeds through the pills.
- The dictate button gets the same blurring at lower opacity
  (bg-card/50 backdrop-blur), staying quieter than the model pills.

Gates: tsc clean, 866/866, lint 0, build ok. Live-verified: pills/dictate
computed styles byte-identical to the reference chrome (bg .85/.5 alpha +
blur(8px) + same shadow stack); title requests 900 in both serif states.
2026-09-23 01:46:15 +03:00
amosblomqvist 525cba0398 Fix wave round 2: serif toggle actually toggles; empty number settings stay empty
- The session-name-serif toggle looked dead because --font-title was an
  @theme INLINE token: the .font-title utility carried a baked copy of the
  family, so the runtime var the toggle wrote was never read by any
  consumer. Moved --font-title to a plain @theme block — the utility now
  reads var(--font-title) and the :root factory default emits — the toggle
  flips the session name's computed font (lh-cmu ↔ Source Sans 3).
- Empty reading-font-size and heading-weight commits wrote the clamp
  minimum (Number("") is 0 → 12 / 100) instead of unsetting. Empty now
  omits the key — absence is the delta schema's unset for numbers — so the
  field stays empty, the record unsets, and the hint carries the resolved
  default (16px / 900). Typed values still clamp visibly (-5 → 12).

Gates: tsc clean, 866/866, lint 0, build ok. Live-verified on :3001.
2026-09-23 01:20:02 +03:00
amosblomqvist 22266973f3 Fix wave: dropdown keyboard/focus, appearance resets, inline key editors, content-sized slider
- MenuShell: after placement, focus lands in the search when the menu has
  one, else the body (tabIndex -1) — the lists' mount-time focus ran during
  the hidden measuring pass and silently failed, so no dropdown ever opened
  focused. The body owns the arrow machine for searchless menus:
  ArrowDown/Up cycle options with wrap (first/last entry from the body,
  native-select convention), Home/End, Enter activates, preventDefault so
  arrows never scroll the page.
- Appearance: the CSS-var applier only ever setProperty'd — a later write
  omitting a var left the stale inline value forever (empty background
  never reset, serif toggle-back looked dead). appliedNamesRef tracks what
  was set; staleVarNames (pure, exported) returns what to removeProperty.
- CommitInput: controlled — after a commit the field snaps to the stored
  record, so the clamp on a -5 prose size is visible (12), Esc reverts.
- Keys: Add-provider dropdown closes on pick; the paste row is inline
  (label | input | Save | Cancel); existing-row editors go inline with an
  explicit Cancel beside Save/Delete — folding is visible, not Esc-only.
- Graph context: readout is content-sized (tabular-nums) — no fixed-width
  dead gap when "off"; the dictation strip fills whatever space remains.

Gates: tsc clean, 866/866 tests (new staleVarNames suite), lint 0, build ok.
Live-verified via Playwright: dropdown focus+arrow cycles+wrap+Enter pick,
background set→empty reset, serif round-trip, visible clamp to 12, provider
pick closes menu, inline editor + Cancel + Esc fold, slider width 270→277px.
2026-09-23 00:57:28 +03:00
amosblomqvist b18294948d feat(keys): LH_IGNORE_ENV_KEYS — the fresh-user switch + dev-fresh launcher
The owner needs exact new-user simulation as a one-command launch
(repeatable first-run BYOK testing). Option A, owner-approved:
env-hiding switch + launcher script.

Fix 1 — LH_IGNORE_ENV_KEYS=1 in the keys seam: when set, every env key
is invisible app-wide; only auth.json counts. The mechanism is boot-time
DELETION, not seam-local filtering: pi-ai resolves provider keys from
process.env NATIVELY at every request (verified — auth/context.js's
defaultProviderAuthContext reads live env, no boot snapshot), so
deleting the managed vars from process.env in initAuthEnv is the ONE
mechanism that hides keys from every consumer — model engines included,
plus the three direct process.env readers (visual-maker, pdf-reader,
openRouterApiKey). realEnv stays EMPTY under the switch (nothing counts
as env), and the fill-gap still reflects auth.json keys so a fresh user
can add keys mid-session and engines see them with no restart.
keyValue reads file-only under the switch as belt-and-suspenders (a
post-boot env reintroduction stays invisible to the seam). Unset
switch = today's behavior, byte-for-byte (server-keys.test.ts pins it).

Fix 2 — scripts/dev-fresh.sh (npm run dev:fresh): fresh throwaway
LH_DATA_DIR (.fresh-data/, wiped each launch, --keep preserves), and —
the subtle bit — LH_AUTH_FILE redirected INTO the fresh dir: dev's
AUTH_FILE default is the repo-root auth.json, which may symlink the
real keys; the fresh instance must never see it. Sets the switch,
dedicated port (default 3005, --port overrideable, standing assignments
3000-3004 refused), prints URL + data dir, safe to rerun. .fresh-data/
gitignored.

Test: tests/fresh-env-keys.test.ts (own process — the switch is
boot-time): the engine-path deletion asserted directly, the env/file
collision resolved to the file, status rows report file sources only,
mid-session saveKey/deleteKey round-trip, idempotent re-init, and the
unset-switch env-wins read seam live-checked.

Live-verified on 3005 via the launcher: zero federated models, all
providers keyPresent false, no present keys, no auth.json/settings.json
in the fresh dir; then mid-session ZAI save → 7 zai models federate
instantly, source 'file' — while .env.local's real keys stayed
invisible throughout.

Gates: typecheck, lint, 863/863, build.
2026-09-23 00:19:16 +03:00
amosblomqvist f80b1a9d2c Fix wave: scroll-lock without layout shift; every models write mirrors the stored record
Fix 1 (owner: "lock must not shift"): the settings dialog's scroll-lock no
longer toggles the content column to overflow-hidden (which hid the
scrollbar and shifted everything right). The column KEEPS overflow-y-auto —
scrollbar and widths stay exactly as they are — and while any MenuShell is
open, non-passive wheel + touchmove listeners on the column preventDefault
so it cannot scroll (owner-ruled mechanism: the scrollbar may stay,
scrolling must not happen, nothing may move). Native listeners via ref —
React attaches synthetic wheel/touch passively at the root.

Fix 2 (owner repro: xhigh pin survives a GLM swap on screen until reload):
every models-writing surface now mirrors SERVER TRUTH, never the request
body. The PUT already returns the full stored record with clamps applied —
settings-dialog write() and the composer's save() now setRecord/setModels
from the response, so a swap that rides an unsupported level shows the
clamped value in the same interaction: the row trigger, the open menu's
level list + checkmark, and the composer pill all read the stored record.

Live-verified the owner's exact repro: deepseek pinned extra high → GLM
swap via the slot menu → trigger/menu/pill showed the stored clamped level
immediately; and the lock cycle: scroll 300 → blocked while open → 300
after release, zero positional shift at engage.
2026-09-22 22:45:11 +03:00
amosblomqvist 5e92d0e73e Fix wave chunk 2: thinking levels auto-populate everywhere; legacy records show the resolved level
- thinking-default.ts: defaultOrderLevel (the ordering's first supported,
  the client twin of the server's defaultThinkingLevel) + populateLevel
  (an explicit level always rides; the WRITE clamps server-side, bd68486,
  and the cross-surface signal re-reads the clamped truth; only absence
  populates from the ordering) + THINKING_LABEL shared.
- Setup dialog (item 5): per-slot thinking selectors pre-populated with
  the default-order level per picked model, editable; disabled until the
  model pick; prefillLevelFromPin keeps a stored level as-is and populates
  legacy pins from the ordering.
- Composer + agent section (item 6): fresh model picks always write a
  level: the stored one rides, absence populates; the unset state can
  never re-enter. Item 4: records with a model but no stored level display
  the RESOLVED level (ordering's first supported) instead of a dash, in
  the composer pill and the settings slot triggers/menus.
- Item 7 verified live end-to-end: an explicit level rides into the PUT,
  the server clamps, the signal re-reads, the pill shows the clamped truth.
2026-09-22 22:05:27 +03:00
amosblomqvist e766a13eb1 Fix wave chunk 1: cross-surface settings signal, dialog scroll-lock, noneRow retired
- 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).
2026-09-22 21:55:33 +03:00
amosblomqvist 9387418864 feat(models): clamp-on-write for pinned thinking levels + DEFAULT_THINKING_ORDER in the core barrel
Fix 1 — clamp, never reject (owner ruling, interlude 2026-09-22): a
model swap with a pinned thinking level must JUST WORK. The owner hit
the write-side 400 live (Claude Fable 5.1/xhigh → GLM 5.3 flash:
"z-ai/glm-5.3-flash does not support thinking level xhigh"). The
supported-level verdicts in validateChatSettings and
validateModelsSettings are DEAD — the WRITE clamps the pin to the new
model's nearest supported level (pi's clampThinkingLevel, the same
translation piEngineForSpec applies at runtime) and stores the clamped
result; the server is the truth — the record shows the clamped level
after write.

New pure helpers in model-catalog.ts, applied by ALL THREE write
routes (section, defaults, per-chat — one verdict seam, so every
surface that used the old 400 gets the clamp uniformly):
- clampModelsLevels: every slot's level clamps against the SLOT'S OWN
  model (the parser already requires a model on each slot write).
- clampChatLevel: the body's own model, else the record's resolved
  strong slot (the chain's fallback); no model anywhere → rides
  unchecked (runtime clamps at engine build; the turn gate refuses
  the model-less chat anyway).

Fix 2 — DEFAULT_THINKING_ORDER moves from config.ts to core/settings
(core barrel, AUTH_KEYS precedent — one source of truth, no drift).
The UI imports it to auto-populate a thinking level on FRESH model
picks; the ordering has no other client role. Legacy unset records
keep the runtime defaultThinkingLevel fallback at engine build
untouched. Import sites updated: agent/models.ts, tests.

Verified live on 3004: the owner's exact case stores max (not 400);
a bare chat-level write clamps to low against the strong slot's glm;
unknown provider/model still 400s with the provider list.

Tests: the level-verdict suites became clamp suites (the owner's case
pinned verbatim: xhigh→max, minimal→low, off→low, supported
pass-through, level-less slots ride); the machine-aware
expectBareLevelVerdict chain is retired — clampChatLevel takes the
strong slot as an explicit param, so the tests are pure again.

Gates: typecheck, lint, 839/839, build.
2026-09-22 21:44:43 +03:00
amosblomqvist 5ab52f07f6 BYOK universal gate rendered honestly: mid-chat setup flow, refusal-aware cards
- 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
2026-09-22 18:12:57 +03:00
amosblomqvist bb7804bd7f docs(prompts): the prober's dispatch tools get their grounded mention
The fix wave (4e1f545) put research + pdf in the prober's registry per
the owner ruling, but the doctrine never mentioned them. Owner chose
(2026-09-22) a brief grounded mention over schema-only discovery: the
paragraph extending 'read his actual material' now covers pdf (same
grounding for his PDF material, no copy-pasting) and research
(sparingly, only when pinning a fact would genuinely sharpen a probe
question — probing is measurement, not exploration, and a research
brief is never a reason to stall the questions).
2026-09-22 17:41:09 +03:00
amosblomqvist 4d5b38b540 fix(runner): the BYOK refusal steers — a dispatched job is never stranded
The BYOK refusal path (engineFor came up empty) only flipped the job
record to error and returned: no steer. But the teacher had already
dispatched — the ack promised a result as a new message — so the lesson
stalled at the dock card with the turn over and the teacher waiting
forever on a brief that would never arrive. Every other failed job
steers (the research/demo/visual/pdf catch paths all deliver the kind's
failure shape through finishJob); the refusal now follows the SAME
convention:

- record the honest error (envelope wording + missing tier) WITHOUT
  flipping status — finalizeJob owns the terminal flip after delivery;
- build the kind's failure result (Research failed:/FAILED:/PDF failed:
  shapes the isSubagentFailure heuristics read) and steer it through
  finishJob, so the wake pipeline resumes the lesson and the teacher
  reads the refusal and tells the learner.

Also cleaned the stale engineFor doc (the model-slots-era block still
said 'factory-pin chain ... absent → config pins' — two comments had
accumulated on the field; merged into one BYOK-accurate block).

Platform's refusal test pinned the old no-steer semantics; updated to
assert the new convention (steer delivered, record flipped by
finalizeJob after). The other piEngineFor callers (teacher-agent,
pi-run, visual-maker, pdf-reader) are deliberate honest-thrower
fallbacks (opts.engine ?? piEngineFor(role)) — missed-injection
detectors, kept as-is.
2026-09-22 17:41:07 +03:00
amosblomqvist eaa963f98b test(model-catalog): machine-aware record assertions — the suite runs green on any tree
Master reported the only red on main: 'serves the keyed providers'
catalogs' pinned defaults/slots NULL, but catalogDefaults/catalogSlots
read the REAL data/settings.json — the owner's tree carries configured
slots, so the payload is legitimately non-null there while the platform
worktree's scratch record is empty. Same class of fragility in two more
tests the scratch run couldn't see: the bare-level validation verdicts
(validateChatSettings / validateModelsSettings) fall back to the
record's resolved strong tier.

data/settings.json belongs to settings-store.test.ts under the
one-fs-owner discipline, so the tests READ the resolved truth instead
of pinning it — the chat-flow 503-gate pattern, applied to all four:

- modelCatalog payload: defaults/slots must carry EXACTLY what the
  machine's record says (configured slots surface as configured, empty
  record surfaces as null) — never a phantom id in either direction.
- catalogSlots: projects each stored tier's model verbatim; a tier
  without a model reads null.
- bare-level verdicts: expectBareLevelVerdict mirrors the fallback chain
  — no resolved tier → rides unchecked; a tier whose model left the
  catalog → rejected honestly; else the supported-level check.

Verified green against four record states: empty, configured (glm with
unsupported minimal), vanished model, and a level-only stored slot
(inert). Gates: typecheck, lint, 825/825, build.
2026-09-22 17:05:44 +03:00
amosblomqvist bfa906e2f8 feat(models): BYOK all the way down — factory pins deleted, per-need refusal gates
Owner decision 2026-09-22: 'it doesn't make sense to have any default
model configured anywhere; the application is bring your own keys.'

config.ts: MODELS deleted; ModelRole becomes an explicit 9-role union;
DEFAULT_THINKING_ORDER (medium, low, high, minimal, xhigh, max) bakes
the unset-level default — first entry the model supports; explicitly
set levels keep pi's native nearest-clamp.

core/settings-resolution: the factory layer dissolves — the chain is
role override → slot → NOTHING. resolveSettings(record) takes no
factory. Empty chains resolve to null/undefined; a thinkingLevel-only
slot is inert (stored, never run). resolveConversationSpec: chat.model
override over the strong slot, null when both empty. resolveRoleSpec
spans all nine roles for the job/extraction engines.

Refusal envelope (core/settings.ts): modelsNotConfigured(missing) —
{error, code: 'models_not_configured', missing: [slot tiers]} +
ModelsNotConfiguredError with .envelope. Per-need gates, each honest:
- chat-handler: universal turn gate (new + continuing turns) returns
  503 before beginTurn — no liveness, no write-ahead persist, no graph
  trigger before the gate fires.
- runner.runJob: unresolvable engine lands the job as error with the
  envelope wording; no phantom loop, no result steer.
- extraction: refuseIfStagesUnconfigured throws before any stage
  (turn-service info-skips so coverage retries next turn; the import
  route surfaces per-file code+missing); labeler-less runs skip
  cluster naming with a console line and still merge.

Write side: slot entries must carry a model — level-only slot writes
are 400s (absent slot key = clear). piEngineForSpec resolves unset
thinking levels via DEFAULT_THINKING_ORDER; piEngineFor now throws
honestly; thinkingLevelFor/modelIdFor deleted; teacher-agent, pi-run,
visual-maker, pdf-reader all read engine.level.

model-catalog: defaults/slots/rolePins null when the chain is empty
(contracts nullable) — no phantom ids in payloads. Stores drop
MODEL_PINS entirely.

Tests: pin-leaners converted to explicit injection (per-chat model
pins on scripted chats — exactly what the setup flow writes; faux
engines for agent-construction tests). New coverage: the 503 gate
envelope, the runner refusal, the extraction refusal + labeler skip.

Gates: typecheck, lint, 815/815, build.
2026-09-22 16:59:16 +03:00
amosblomqvist 8675b19bef Fix wave chunk B: shared MenuShell for model menus, setup CTA on /chat/new
- components/menu-shell.tsx: one portaled, fixed-anchored, viewport-capped
  (60vh pattern) menu shell with site-preferred side + honest-fit auto-flip;
  computeMenuPlacement exported pure, placement-only (sites keep their own
  open/close/Esc contracts); re-places on capture-phase scroll/resize
- migrated: settings slot menus (agent-section), keys Add-provider,
  composer model+thinking (open up, right-aligned), SetupDialog pickers
  (portaled out of the overflow-hidden dialog frame — no clipping by
  construction); graph popovers + stats-bar summary stay bespoke (owner)
- /chat/new: the setup CTA above the draft blob, mirroring home's
- tests: menu-shell-pure (geometry/cap/flip/alignment), DraftShell CTA
2026-09-22 16:56:44 +03:00
amosblomqvist f6626f1703 Fix wave chunk A: no Default option, composer = strong slot, catalog revalidation
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.
2026-09-22 16:40:31 +03:00
amosblomqvist d85d8a58a0 fix(tools): wire the searchAvailable gate + research/pdf in the probe phase
Fix wave (2026-09-20), two owner-approved fixes:

Fix 1 — the searchAvailable gate was dead code on the live wiring path:
createTeacherTools RECEIVED ctx.searchAvailable but dropped it when
building the inner JobToolContext, so jobTools' research gate always took
its ?? true default (jobTools-direct tests passed; the production path
never saw the signal). Threaded through — the gate now reads the live
server signal on the teacher registry too.

Fix 2 — the prober registry gains the research + pdf dispatch tools
(owner ruling, 2026-09-20): these two are phase-omnipresent — the prober
can verify facts or read learner PDFs while assessing. demo/visual stay
teach-phase-only (they need the lesson's plan context). Same gating as
the teacher side: toggle-filtered, research subject to the search gate
(honestly absent when no engine is live — the prober's dispatch closes
over the SAME jobTools builder, one gate, one semantics). probe_done
stays always-on-by-construction; the learner-fs set untouched.

- ProberToolContext (extends ProbeDoneContext with the job wiring);
  createProberAgent takes it; chat-handler injects jobStore + spawn +
  searchAvailable into the prober ctx.
- TeacherToolContext.spawn's stale input union fixed (PdfInput was
  missing from the TYPE — runtime already passed it).
- Tests: the threaded-gate describe in web-search.test.ts pins the LIVE
  factory path both ways (teacher + prober); question-tools' registry
  key-sets gain research/pdf; the prober toggle test pins the
  research:false path.

Gates: typecheck, 807/807 tests, lint, build — all green.
2026-09-22 16:22:57 +03:00
amosblomqvist 57dd25993c Research tool row grays on the registry's own search signal (Teacher flag)
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.
2026-09-22 14:04:15 +03:00
amosblomqvist 372d7f9975 feat(extraction): stage engines resolve the slot chain — engineFor on the pipeline
Chunk B, item 3 of the search port + slots wave (2026-09-20): the flagged
gap closed — ExtractionModels deps were engine VALUES, keeping
extractor/auditor/labeler config-pin-bound while the job runner went
slot-native. The pipeline now takes the runner's exact treatment:

- ExtractionPipelineDeps.engineFor: (role) => PiEngine — resolved FRESH
  per stage call, so a settings PUT between stages lands. Direct test
  values (extractor/auditor/labeler) remain the priority seam, checked
  FIRST; absent values fall to the chain; absent chain → config pins
  (unchanged).
- All six stage call sites (chat + notes extract/audit, the cluster
  namer x2) resolve through stageEngine(role, direct).
- chat-handler's extraction composition injects the full chain —
  piEngineForSpec(resolveRoleSpec(role, readSettings(), MODEL_PINS)) —
  same wiring as the job runner.

Chunk B, item 4 (session-level slot-anchored model picks): VERIFIED
already complete from Platform's model-slots wave — the conversation
engine chain (chat-handler) anchors on effective.models.strong, the
picker's default surface (catalogDefaultsFor) falls back to strong,
both pinned in tests. No phase split (user decision, 2026-09-20).
Capability-filtered pickers live in the settings UI (main 343be10).

Tests: engineFor fills the stages end-to-end (both propositions merged
through the chain, consulted in pipeline order) + the direct-value
priority pin (engineFor that throws when consulted).

Gates: typecheck, 795/795 tests, lint, build — all green.
2026-09-22 13:59:25 +03:00
amosblomqvist ed264c936d feat(search): web_search onto the injected port — Google CSE retired, research honestly disabled
Chunk A of the search port wave (2026-09-20), per master's assignment:

- web.ts: the Google Custom Search backend is gone — web_search now runs
  on the core search port (SearchProvider), injected as a dependency
  (import law: the tool never imports server code). createWebTools(search)
  resolves the LIVE provider fresh per call; null = the honest
  unavailability string (no silent fallback, owner decision). web_fetch
  unchanged (needs no engine).
- runner: JobRunnerDeps.searchProvider — the server composition threaded
  into the researcher; jobs already in flight when a key disappears still
  fail honestly through the same seam, never crash.
- jobTools: the research dispatch tool DROPS from the teacher's registry
  when ctx.searchAvailable() is false (owner decision: registry-level
  drop — the model never sees the tool, no doomed dispatch, no error
  card). Absent seam = available (tests). chat-handler injects
  server/searchAvailable + searchProvider.
- tests: stubFetch (the Google wire stub) retired; jobs.test.ts threads
  a recording stub provider through all four research runners and asserts
  the scripted query hit the port; new web-search.test.ts covers the
  factory contract (null → honest string, hits, error strings, fresh
  resolution, web_fetch independence) + the registry drop matrix.

Gates: typecheck, 793/793 tests, lint, build — all green.
2026-09-22 13:59:25 +03:00
amosblomqvist 48fbd988f1 Wave-5 chunk B: three model slots, search-engine row, first-run setup flow
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.
2026-09-22 13:54:29 +03:00
amosblomqvist 21d288e9f9 Keys section polish (wave-5 chunk A, owner design): the minimal key-value table
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.
2026-09-22 00:16:23 +03:00
amosblomqvist f9e3a4c43d feat(models): strong/cheap/vision model slots + multi-provider engine federation
Model slots wave (2026-09-20), chunk B:

- settings: new `models` section {strong, cheap, vision} slots (each
  field independent, same shape as a role override); ROLE_SLOT maps all
  nine roles to tiers (strong=prober+teacher, cheap=researcher/
  demoBuilder/extractor/auditor/labeler, vision=visualMaker+pdfReader);
  resolution chain per role: override → slot → factory pin.
- config: ModelSpec gains provider (all pins openrouter, the BYOK
  default); MODEL_PINS in stores.ts now spans all nine roles (the strong
  slot's factory rep IS the prober pin).
- engine: piModels() registers EVERY builtin provider catalog;
  piEngineForSpec(resolved spec) is the new engine factory (pi-ai
  resolves per-provider keys from env itself); piEngineFor stays as the
  config-pin fallback. chat-handler resolves the conversation chain
  (chat override → strong slot → pin) and injects engineFor into the job
  runner, closing the old gap where per-role overrides never reached
  job engines.
- /api/models: models federate ONLY keyed providers (providers surface
  with keyPresent, env-wins, fresh per call); slots (resolved tier pins)
  join rolePins; catalogDefaultsFor inherits the strong slot; the models
  settings section gets the same catalog gate as chat.
- pricing: PRICES/CONTEXT_LIMITS uniformly keyed provider:id
  (regenerated from ALL pi-ai catalogs, 1354 keys); usage stamps
  (TurnUsage, JobUsage, extraction stages) switch to provider:id —
  owner decision: no legacy compat, old stamps render unpriced.
2026-09-22 00:14:56 +03:00
amosblomqvist f1373d5de5 feat(search): Tavily + Brave behind a search-provider port; Google CSE retires
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).
2026-09-22 00:02:07 +03:00
amosblomqvist bd66b31853 Keys section (auth/provider-keys wave): BYOK keys in the settings dialog
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.
2026-09-19 22:53:15 +03:00
amosblomqvist ec38f91a2b feat(keys): free-form model-provider keys — pi-ai provider table on /api/keys
Provider-keys interlude (owner spec, 2026-09-19): any pi-compatible
provider's key is manageable, not just the curated set.

GET /api/keys now also returns providers: {id, envVar, label}[] — every
pi-ai provider that takes a plain api-key env var (33 today), derived
from pi-ai's own provider→env-var table. No-drift mechanism: the table
is generated from pi-ai (builtinProviders + findEnvKeys over a
truthy-for-every-var proxy env) and pinned by
tests/auth-provider-keys.test.ts, which re-derives the truth at test
time and asserts EXACT equality — a pi-ai upgrade fails the gate loudly.

Owner-spec exclusions: github-copilot (OAuth), amazon-bedrock (AWS),
google-vertex (ADC), azure-openai-responses (base URL), the cloudflare
pair; anthropic keeps ANTHROPIC_API_KEY only. OPENROUTER_API_KEY
graduates from the curated catalog to the provider table (dedupe — the
UI pins it always-visible); curated stays Deepgram + the Google search
pair. Statuses: curated + present vars + unknown file vars — absent
provider keys never spam rows; the dropdown is discovery.

Write side: PUT/DELETE accept provider env vars (generic truncation
guard — trim, no whitespace, ≥12; family checks stay on the curated
three). Present-in-env provider vars surface as status rows so
env-wins/shadow semantics stay honest.
2026-09-19 22:52:40 +03:00
amosblomqvist e6dfa6faa4 Merge branch 'feat/ui-polish' 2026-09-19 22:39:38 +03:00
amosblomqvist a3b6a495d3 Merge branch 'feat/edge-glyph' 2026-09-19 22:39:03 +03:00
amosblomqvist 3bec12efd1 Merge branch 'feat/platform' 2026-09-19 22:38:45 +03:00
amosblomqvist fddbacd330 Settings-app refinement round 3 (owner review): toggle polish, nav spacing, portal dropdowns, Default-named hints
- 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.
2026-09-19 22:29:17 +03:00
amosblomqvist 0629e71a50 Settings-dialog round 1 staging repair: the provider-rewiring files
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.)
2026-09-19 22:29:17 +03:00
amosblomqvist fd63e4a575 Settings-app refinement round 2 (owner review): toggle fix, shared model dropdown, groups, graph-view graying, pruned hints
- 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.
2026-09-19 22:29:17 +03:00
amosblomqvist 5e21561997 Settings-dialog round 1b: un-double the section inset
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.
2026-09-19 22:29:17 +03:00
amosblomqvist 77527a9766 Settings-app polish round 1: the thin page graduates into the designed settings dialog
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.
2026-09-19 22:29:17 +03:00
amosblomqvist cc1e3193f4 feat(models): rolePins on GET /api/models — the background roles' resolved pins
Settings interlude (owner request, 2026-09-19): the route now also returns
rolePins: Record<BackgroundModelRole, {model: {provider, id},
thinkingLevel}> — the six background roles' RESOLVED models (the user's
agent-models override falling back to the config.ts MODELS pin), so the
settings UI shows each role row's 'Default: <actual pinned model>' instead
of a generic config caption. Same resolution the agent factories use
(effectiveSettings), so the caption can never drift from what runs.

Pure projection (rolePinsFor) + the fs wrapper (catalogRolePins); pins
tested against config defaults and model/thinkingLevel-only overrides.
BACKGROUND_MODEL_PINS exported from stores.ts for the test (was local).
2026-09-19 22:28:25 +03:00
amosblomqvist a828d9b6d9 feat(models): rolePins on GET /api/models — the background roles' resolved pins
Settings interlude (owner request, 2026-09-19): the route now also returns
rolePins: Record<BackgroundModelRole, {model: {provider, id},
thinkingLevel}> — the six background roles' RESOLVED models (the user's
agent-models override falling back to the config.ts MODELS pin), so the
settings UI shows each role row's 'Default: <actual pinned model>' instead
of a generic config caption. Same resolution the agent factories use
(effectiveSettings), so the caption can never drift from what runs.

Pure projection (rolePinsFor) + the fs wrapper (catalogRolePins); pins
tested against config defaults and model/thinkingLevel-only overrides.
BACKGROUND_MODEL_PINS exported from stores.ts for the test (was local).
2026-09-19 22:28:11 +03:00
amosblomqvist d90b1caa23 Settings dropdown: widen for a clean scrollbar gutter
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.
2026-09-19 22:14:11 +03:00
amosblomqvist 7555da03b9 Top-row chrome: composer-matching 16px corners, equal 8px gutters
- 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).
2026-09-19 22:09:37 +03:00
amosblomqvist 3de911aa58 Top-row chrome: dropdowns layer above the cost number; slight (4px) corners
- 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.
2026-09-19 22:06:19 +03:00
amosblomqvist 4e39f04cfa Header band: one row of controls, 36px, aligned with the sidebar toggle
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.
2026-09-19 22:02:19 +03:00
amosblomqvist 138d3d8ce6 Cost control: bare top-right number with dropdown; pipeline pills move under it
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.
2026-09-19 21:55:02 +03:00
amosblomqvist 3e263a18cc Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel
- 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.
2026-09-19 20:55:35 +03:00
amosblomqvist cbaf6df454 feat(keys): first-class BYOK key management — store, catalog, /api/keys
The auth/keys wave (2026-09-19): the server now owns the flat auth.json
map directly (LH_AUTH_FILE from main.cjs; repo-root auth.json in dev,
gitignored). The store reflects itself into the child's env at boot with
fill-gap precedence — real env (incl. .env.local in dev) always wins —
so every consumer keeps its plain process.env reads and saved keys take
effect immediately, no restart; main.cjs's spawn-time injection retired
(an env copy would shadow every settings-page change).

Curated 4-key catalog (OpenRouter, Deepgram, Google search pair) with
shape-only validation (no live pings — owner decision), 400s on bad
pastes, last-4+length masking (plaintext never leaves the store),
unknown file vars preserved + surfaced. Routes: GET /api/keys,
PUT/DELETE /api/keys/:id. Tests: catalog/store/composition, with the
env-sanitizing prologue (a dev shell may export real keys; tests use
throwaway values only).
2026-09-19 20:54:16 +03:00
amosblomqvist c32915b232 Thin working settings UI (owner-approved plan): /settings + live appearance + keyboard rebinding
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.
2026-09-19 18:27:37 +03:00
amosblomqvist 524bf6024a feat(prompts): composeInstructions honors overrides — the P2 segment reconciliation
The other half-wired seam: Platform's settings 'agent.prompts' switches
had no consumer in the agent layer, and their effectivePromptSegments
mirrored the PRE-sources-v1 loader — the shared/ fragments dir (the
learner-files doctrine) was invisible to it, so a naive delegation would
have silently dropped the segment from both agents' prompts.

- src/core/prompt-segments.ts (NEW, pure): the mirror-layout path
  grammar + segment types, defined exactly once; core stays zero-IO.
- src/agent/prompt-segments.ts (NEW, the CANONICAL discovery): base +
  shared/ + per-agent fragments, override resolution, allSegmentPaths.
  Composition is an agent concern; the agent layer reads its own tree
  (the architecture law pins agent and store apart — the first direct
  refactor attempt tripped the enforced import/no-restricted-paths
  zone, hence the three-layer split).
- src/store/prompt-overrides.ts: slimmed to the override-file CRUD
  behind the core port; shares the core grammar. Its doc no longer
  claims to be the canonical mirror.
- teacher-agent: composeInstructions(kind, switches?) composes through
  effectivePromptSegments — a segment whose switch is ON and whose
  override file exists composes the override text. Switches-less calls
  use the cached pure-source composition (byte-identical to the old
  loader, pinned by test); AgentBuildOptions.promptSwitches threads
  effective.agent.prompts per turn, composing fresh so override-file
  writes flip the very next turn.
- server barrel: re-exports the agent module's resolution for the
  settings routes (server→agent, the lawful direction).

Tests: byte-identical source pin, override swap-in (on/off/file-absent),
shared/ overridability in both agents, agent-construction threading,
updated segment-list pins with shared/ in composition order. This file
keeps owning PROMPTS_OVERRIDE_DIR (parallel-test discipline).

Gates: 671/671, typecheck, lint, build.
2026-09-19 18:21:09 +03:00
amosblomqvist 6c42393e42 feat(tools): the P2 toggle bridge — toolToggles first-class, definitive vocabulary
Reconciliation of the diverged tool-toggles seam (2026-09-19, master's
P2): Platform injected the resolved delta as an ad-hoc intersection on
AgentBuildOptions while the factories' enabled? param sat unread — the
seam was inert on both sides.

- teacher-agent: AgentBuildOptions gains toolToggles?: EnabledTools;
  both constructors thread it to the factories (createTeacherTools /
  createProberTools). An explicitly injected opts.tools registry is
  never filtered — the caller owns it.
- Vocabulary ADJUDICATED with the user (2026-09-19): TOGGLEABLE_TOOLS is
  the definitive list — the job tools only (research/demo/visual/pdf).
  quiz/ask_user_question stay always-on forever (the prober's flow is
  graded-quiz brackets -> probe_done; quiz-off collapses assessment and
  teaching doctrine is built on graded verification — the wedge danger);
  the learner-fs set, plan tools, and probe_done also always-on.
- settings-body (write side): unknown tool keys reject with 400 + the
  supported list, mirroring the prompt-switch validation; the read-side
  sanitizer stays lenient for forward-compat.
- chat-handler: the intersection is gone (field now first-class) and the
  seam comment states the adjudicated vocabulary.

Gates: typecheck, 28 new/updated tests green (tool-toggles-bridge,
question-tools, settings-body), chat-flow + settings suites green.
2026-09-19 18:16:17 +03:00
amosblomqvist cb4fe25c89 wave 2 (platform stream): settings storage & plumbing foundation (P2)
- src/core/settings.ts: versioned DELTA record schema + sanitizer (unknown keys preserved)
  — general/chat/agent{models,tools,prompts}/appearance/graph sections
- src/core/settings-resolution.ts: pure resolveSettings (factory defaults → user delta),
  extraction/graph-context channel predicates
- src/store/settings.ts + prompt-overrides.ts: SettingsStore/PromptOverrideStore adapters
  wired through stores.ts (effectiveSettings() fresh per call); lazy migration of
  default-settings.json into record.chat
- app/api/settings/** routes (record, section bodies, prompt overrides per segment)
- chat-handler: graph gates at trigger call-sites + toolToggles injection seam
- config.ts: SETTINGS_FILE + PROMPTS_OVERRIDE_DIR paths

587/587 tests; typecheck/lint/build clean
2026-09-19 18:05:30 +03:00
amosblomqvist 36c5d19437 fix(tools): the abort cascade — kill running tool calls, honest activity labels
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.
2026-09-19 17:26:57 +03:00
amosblomqvist 1b9122c88b fix(pdf): split pagesRead from pagesConsulted — the honest badge count
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).
2026-09-19 16:52:09 +03:00
amosblomqvist 7a3e19e95f feat(sources v1): the agent locates files itself — ls/find/grep + tool-call cards
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.
2026-09-19 16:50:20 +03:00
amosblomqvist dfe1a21989 fix(pdf): render the pdf dispatch card (live browser-smoke gap)
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.
2026-09-19 16:27:11 +03:00
amosblomqvist 2780166904 fix(pdf): make pdfjs survive bundling + real-PDF font fidelity (live smoke)
Live dispatch smoke against the Turbopack dev server (master's ask,
2026-09-19) caught two things unit tests cannot - they run unbundled:

- Bundling pdfjs-dist broke its node fake-worker: the chunk-relative
  pdf.worker.mjs import 404s inside .next/dev/server/chunks. Fix:
  serverExternalPackages + tracing includes for legacy/ + standard_fonts/
  (next.config.ts - a clearly-marked seam, same precedent as bcdcd51; the
  standalone output now ships the worker and font files, verified).
- Real-world PDFs warned about missing standardFontDataUrl (non-embedded
  standard fonts silently fall back) and flooded the log with JBIG2
  warnings on scanned files. Fix: standardFontsDir() + verbosity 0 in
  both the node ops and the headless canvas shim.

Verified live end-to-end on GEB (802pp, text layer): teacher dispatch,
12-step read with vision renders, brief with accurate page citations,
wake turn folding the resolution (Chapter IX, pp. 268-270) into the
lesson. Scanned/DRM Polya correctly refuses to fabricate citations.
2026-09-19 16:23:20 +03:00
amosblomqvist 31f600a365 Merge branch 'feat/sub-agents' 2026-09-19 16:01:02 +03:00
amosblomqvist 9a90dd51d9 feat(T2b): sources v0 — read tool + pdf-reader sub-agent
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.
2026-09-19 15:24:26 +03:00
amosblomqvist bc2c02b865 seam (master-approved): MODELS.pdfReader for the T2b pdf-reader sub-agent
One key, master pre-approved content (2026-09 master sync): the pdf-reader
is of the teacher/researcher family — dense image→text extraction wants the
stronger multimodal; qwen/qwen3.8-flash stays specialized for visualMaker
(generation). Owner can override in one line after live testing.
2026-09-19 15:00:10 +03:00
amosblomqvist cab1632c74 feat(T2a): tool-toggle seam + phase-based question principles
Part 1 — tool availability (the settings seam, machinery generic):
- core/pending.ts: TOGGLEABLE_TOOLS (today: research, demo, visual —
  the model-facing tool names), EnabledTools = the JSON-native seam
  shape Partial<Record<string, boolean>> (absent key = enabled, so old
  records behave exactly as today), filterEnabledTools shared helper.
  quiz/ask_user_question are always-on FOR NOW (user-decided); the
  filtering is generic so future toggleables (question-tool variants,
  the read tool, …) need no factory change.
- createProberTools/createTeacherTools take enabled?: EnabledTools; the
  composition root (chat-handler, Platform's lane this wave) passes the
  resolved settings through. probe_done is re-added after the filter —
  always-on by construction (it ends the phase; a prober that can never
  conclude is a lockout).
- Prompts stay STATIC under toggles this wave (user-decided); standing
  principle recorded: tools ship self-contained usage guidance in their
  descriptions; system prompts reference only always-on tools.

Part 2 — phase-based question-writing principles (user-locked wording,
drafted with the user over three iterations):
- NEW teacher/21-question-principles.md (composes after 20-teaching):
  answerable-by-reasoning-from-the-session (the fairness rule that
  filters unanswerable questions without forbidding visible answers),
  never-just-reciting-your-words-back, PREFER problems that apply the
  knowledge (preference, not hard rule — honest recall is the fallback,
  not the habit), catch-confusion-early-and-nameable (wrong options as
  real misconceptions), answer-is-signal-not-verdict.
- prober/10-probe-doctrine.md: 'no rush' retired; quick-measurement
  directive in — every question moves an edge (bracket, narrow, or
  characterize); a bracketed strand is done.

Tests: TOGGLEABLE_TOOLS pin, prober/teacher filtering (absent = full,
false drops exactly that tool, probe_done survives), prompt pins (the
principles compose verbatim into the teacher prompt, the no-rush
framing is gone from the prober). 547/547, typecheck/lint/build green.
2026-09-19 14:06:07 +03:00
amosblomqvist 8d4c219f0c docs: probe-teach-split — the plan-acceptance data part is data-verdict (as the code has always sent); flagged during wave 1 2026-09-19 13:17:15 +03:00
amosblomqvist 487f5d4cea wave 1 (platform stream): background-observability accuracy + owner-driven follow-ups
- observability: liveness-gated stale-job recovery, extraction run registry on globalThis with
  explicit 'embedding' phase, /api/jobs force-dynamic, client refresh nudges at every seam
- headless: providerSafePng canonicalizer (multi-IDAT Chrome PNGs → single-IDAT) fixing qwen 400s
- composer: Enter routes through pure enterAction — accepts a pending plan verdict (owner decision)
- markdown chrome: session-scoped mermaid overlay + md-blocks component set + pan-zoom math,
  corner-copy (clipboard grant in electron main), math/table/code block fixes, heading weight 900

543/543 tests; typecheck/lint/build clean
2026-09-19 13:16:34 +03:00
amosblomqvist cbecb8df03 ui: rename learner-facing 'knowledge context' copy to 'graph context' (components + render tests) 2026-09-18 18:03:25 +03:00
amosblomqvist 4f83fd4827 docs: add the no-guessing master directive to AGENTS.md — all design decisions come from the human; ask, never guess 2026-09-18 17:47:31 +03:00
amosblomqvist 9387ddfe5d docs: re-ground docs to the as-built system — architecture.md rewritten from a full-code audit (pre-audit spec archived with annotations), README updated, contracts list fixed, CLAUDE.md removed 2026-09-18 16:54:25 +03:00
amosblomqvist bd3086194f feat: no baked-in default model — the new-session controls ARE the default settings page
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.
2026-09-18 09:37:20 +03:00
amosblomqvist a94a108e74 feat: draft settings persistence — shared model/thinking/context record for the new-session surfaces
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.
2026-09-18 09:25:40 +03:00
amosblomqvist 597b3f43e1 fix: wake-turn steer delivered twice — claimSteers re-claimed the steer the pump's request already carried
The wake pump fires its turn with the steer INSIDE the request body but
dequeues the queue entry only after the turn returns 200 — so the entry
is still queued when the handler claims. The record doesn't carry the
steer yet (write-ahead persist hasn't run), so claimSteers claimed it a
SECOND time and the handler appended it: outgoing = [...body, ...claimed]
held two copies of the same deterministic steer id, and the write-ahead
persist saved that for the whole wake turn. The client's adoption poll
rendered both -> React duplicate-key warning (steer_ctx_<chatId>).
withMessageIds deduped only at onFinish, so the final record looked clean.

Fix: claimSteers(chatId, incoming) — a queued steer whose id is already in
the turn's incoming list is being delivered BY this very turn: finalized
and resolved, never appended again.
2026-09-18 09:13:36 +03:00
amosblomqvist 9324793a23 fix: walked model row gets its outline back — inset ring over the accent tint
ring-1 ring-inset paints INSIDE the border box, so the overflow clip
can't shave it (round 5 follow-up: make the highlight stand out a bit
more).
2026-09-18 08:58:53 +03:00
amosblomqvist e14fad1709 fix: model menu highlight matches quiz rows — filled accent tint, no clipped ring
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).
2026-09-18 08:57:10 +03:00
amosblomqvist 61bd43282d feat: model menu keyboard machine + graph center-anchor on resize
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.
2026-09-18 08:53:31 +03:00
amosblomqvist 76ea6f5a53 fix: kill the UA blue focus outline on question rows (owner round 4)
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.
2026-09-18 08:41:36 +03:00
amosblomqvist ab5d5da9fb fix: keyboard round 3 — question walk clamps, Tab focus toggle, Alt+arrow isolation, search Esc
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
2026-09-18 08:34:46 +03:00
amosblomqvist 8b158972db feat: keyboard waves A/B — global shortcut registry, question row machine, Esc-cancel dictation
Wave A (owner key map): registry in src/core/keyboard.ts + KeyboardShortcuts
provider; Cmd+D dictation, Cmd+K model picker, Shift+Tab thinking cycle,
Cmd+B sidebar, Cmd+Shift+F sessions search, Alt+arrows session cycle,
Alt+Enter open, Cmd+G graph, Cmd+N new, F2 rename, ? cheat sheet. Tab
fully suppressed; Esc chain popover > dictation > abort (local stream).
Esc now CANCELS dictation (discard) — flush stays on Cmd+D/Stop. Owner
bug reports fixed: focus outline removed, Alt+Enter setState-in-render,
Cmd+F graph node search (surface-scoped lh:kbd-find).

Wave B: question dock row machine — arrows walk options + dont-know/Other
with wrap + ring highlight, Space picks (mirrors click semantics), Enter
submits when canSubmit; typing surfaces and in-dock keys keep native
behavior. aria-checked={isSel} restored on option buttons.

docs/keyboard.md records the full map.
2026-09-17 21:18:56 +03:00
amosblomqvistandClaude Fable 4.5 plus-plus 682edde844 feat: keyboard shortcuts wave A — registry, provider, global bindings
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>
2026-09-17 20:47:23 +03:00
amosblomqvist d1ba0c5c3c fix: graph settings panel viewport-capped like the coverage dropdown
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.
2026-09-17 20:16:12 +03:00
amosblomqvist 2d3f0e0412 feat: dictation round 2 — deferred transcript, adaptive meter
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.
2026-09-17 20:06:07 +03:00
amosblomqvist 4dbbfa6fe7 feat: dictation round 1 — primary-input routing, no interims, optimistic meter, wide button
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.
2026-09-17 19:59:14 +03:00
amosblomqvist 573e369db5 fix: dictation cloud path — bearer subprotocol, access_token grant field
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.
2026-09-17 19:44:35 +03:00
amosblomqvist e3bdbc2f2c feat: voice dictation (STT wave 1 — Deepgram cloud)
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.
2026-09-17 19:31:25 +03:00
amosblomqvist d46c3dae93 feat: narrow-window behavior — sidebar auto-close, chrome wrap, 640px floor
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).
2026-09-17 18:57:02 +03:00
amosblomqvist a20e1f1fb5 fix: dark native chrome — no white flash on resize or first paint
Live-resize frame lag: the native window surface resizes before the
compositor has a frame at the new size; the gap showed Electron's
default WHITE window background — glaring on a dark app, at the edges
on shrink. The standard treatment, all three:

- BrowserWindow backgroundColor '#141414' fills the lag with the app bg
- show: false + ready-to-show — the window appears only after the page
  has rendered (kills the first-paint white window)
- color-scheme: dark — native widgets and scrollbars render dark

Owner resize-verified in the Electron dev shell.
2026-09-17 18:43:35 +03:00
amosblomqvist 9863674a76 fix: Electron drag mask resolves by DOM order, not z-index
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.
2026-09-17 18:33:59 +03:00
amosblomqvist 6e08d77ec2 docs: STT research — verified Deepgram + whisper.cpp findings and decisions
Two researcher passes (2026-09-17), cross-checked against our codebase:
Deepgram streaming (WebSocket, Nova-3, SDK v5, $0.0048/min streaming
promo, $200 credit), AudioWorklet linear16 capture (MediaRecorder webm
has unresolved empty-transcript reports), endpointing/utterance_end/keep-
alive gotchas, 127.0.0.1 secure context, server-minted short-lived access
tokens (renderer never holds the real key); whisper.cpp CLI child process
(Metal RTF base 0.04/small 0.08, base.en 142MB/small.en 466MB, MIT) with
per-utterance batch flow and LH_DATA_DIR model cache. Decisions: toggle
mic, cloud-first wave scope, renderer->wss direct.
2026-09-17 17:18:41 +03:00
amosblomqvist fb759b7aa0 fix: top-bar polish — lights on the pill line, rename escape hitbox, inert phase pill, chrome carve-outs
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.
2026-09-17 17:18:41 +03:00
amosblomqvist 9a6d422d59 feat: merged title bar — drag bands, owner's mark, title-width rename hit-box
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.
2026-09-17 16:55:07 +03:00
amosblomqvist 7902984bd6 feat: Electron desktop wrapper — LearnHarness.app (macOS arm64)
The web app becomes a desktop app without changing shape: the Electron
shell spawns the Next standalone server on a loopback port and points a
sandboxed window at it. All app logic stays in the Next server.

Bands:

1. LH_DATA_DIR seam (config.ts): PATHS.data/chats/graph derive from
   process.env.LH_DATA_DIR (else repo data/, unchanged in dev). New
   PATHS.modelCache routes the transformers.js embedding-model cache
   (~300MB) to <data>/models when the seam is set — the default cache
   inside node_modules is read-only in a packaged bundle. All existing
   fs writes flow through PATHS; teacherPrompt stays ROOT-anchored
   (read-only asset, ships inside the bundle by design).
   tests/data-dir-seam.test.ts covers set/unset via child process.

2. Standalone build (next.config.ts + scripts/build-server.mjs):
   output 'standalone'; outputFileTracingExcludes data/**/* keeps the
   repo's real session data out of the bundle (verified: an unexcluded
   scratch build traced 34 files / 716K of real chats + ledgers).
   Two empirically-verified tracer gaps required outputFileTracingIncludes:
   pi-ai's provider catalog (JSON import attributes — 39 files, 500s on
   /api/models without them) and onnxruntime-node's sibling dylib
   (libonnxruntime.dlopen via @rpath). build-server.mjs copies public/
   and .next/static per docs, refuses a leaked data/ dir, and asserts
   the runtime-read prompt .md assets are traced.

3. Electron shell (electron/main.cjs, no preload):
   - single-instance lock (two instances would fight over the data dir);
   - free port via listen(0); spawn via process.execPath +
     ELECTRON_RUN_AS_NODE=1 with PORT/HOSTNAME=127.0.0.1/LH_DATA_DIR;
   - BYOK: userData/auth.json, flat {VAR: value} JSON, mode 0600,
     merged into the child env only when absent from the real env
     (auth UI is the settings milestone; file is hand-editable);
   - poll until the server answers (no ready event), then open a
     contextIsolation+sandbox window; deny-by-default permission
     handler (media allowed only from the loopback origin — mic/STT
     groundwork); will-navigate pinned to origin, window.open denied
     with external links to the system browser; macOS Edit-menu roles;
   - quit path SIGTERM→3s→SIGKILL + exit/SIGHUP nets; child stdout
     mirrored to userData/logs/main.log;
   - dev mode via LH_DEV_URL: no spawn, points at next dev (HMR).

4. Packaging (electron-builder.yml + build/entitlements.mac.plist):
   files = electron shell only; the whole .next payload ships via
   extraResources from .next (filter standalone/**/*) — NOT from
   .next/standalone directly: electron-builder hard-excludes a copy's
   root node_modules and the default pattern is a literal match, so
   node_modules/ and .next/ were silently dropped and the server died
   with 'Cannot find module next'. Asar holds no native code.
   Unsigned (identity: null) until a Developer ID cert exists; hardened
   runtime + audio-input entitlements laid now for the STT milestone.
   desktop:dev / desktop:dist scripts (electron 44, electron-builder 26).

Live smoke of the packaged .app: server up on a random loopback port,
all API routes 200, a real GLM chat turn answered end-to-end via
auth.json BYOK, chat persisted under userData/data, second instance
exited via the lock, no orphaned server child after quit.

Gates: typecheck, eslint, 500/500 tests (+2 data-dir-seam), build.
2026-09-17 15:32:28 +03:00
amosblomqvist 769a3da186 feat: session rename + graph chrome polish (popovers dismiss, no-shift no-match, coverage reseat)
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.
2026-09-17 14:28:20 +03:00
amosblomqvist 5c380de669 Merge lh-ui refinement wave (feat/ui-polish @ 8163437) into main
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.
2026-09-17 14:11:23 +03:00
amosblomqvist 40870544ba Header round 6d (owner): the title grows to text-3xl — matched in the draft shell 2026-09-17 14:09:22 +03:00
amosblomqvist 64c644f42c Header round 6c (owner): the sidebar toggle owns an animated slot — no more one-frame jumps
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.
2026-09-17 14:04:43 +03:00
amosblomqvist 9ddeb60e3f Header round 6b (owner): the open-sidebar toggle matches the close glyph — size-4 both ways
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.
2026-09-17 13:55:40 +03:00
amosblomqvist c451f4f144 Header round 6 (owner): the top bar grows to 60px — aligned to the sidebar's LH band
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.
2026-09-17 13:52:18 +03:00
amosblomqvist 70ddc21610 Typography round 5 (owner): drop the message count + active-row weight; ISO dates
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.
2026-09-17 13:48:04 +03:00
amosblomqvist 2a8554af27 Typography round 4 (owner): the snap to the ladder — prose 16.5→16, xxs rung merged into xs
The system: the ladder is the truth, everything snaps to it.

- Prose snaps to the ladder's 16px rung (--lh-prose-size 16.5→16; the
  half-pixel was a reskin-era hand-tune with no visible payoff and a
  system cost). lh-prose, lh-input, and the session titles all read the
  one variable — one reading size, now a ladder citizen. @theme inline
  inlines literals without emitting runtime vars, so the variable can't
  var()-link to --text-lg: documented as the one deliberate duplication
  (change one, change both).
- The 10px rung merges into 11px: one pixel apart was a distinction with
  no visible difference, and 10px sits at the mono legibility floor —
  quiet labels (Sessions, Background agents, dock hints, model-picker
  footers) get their quiet from color + tracking, not a notch of size.
  All 13 text-xxs uses → text-xs; --text-xxs token removed.
- Derived constant follows: TEXT_CAP_SCALE 16.5/12.5 → 16/12.5 (1.32 →
  1.28; test pin 232.32px → 225.28px) — the plan-graph text cap now
  tracks the snapped prose size.

Live: title 16px, Sessions/Background-agents labels 11px, composer 16px
with the 20px lh-input line box. Gates: typecheck, lint, 469/469.

Lane note: components/graph-context-marker.tsx (graph-stream file) had a
mechanical text-xxs → text-xs swap — required: the token removal
otherwise leaves a dead class in their file.
2026-09-17 13:43:26 +03:00
amosblomqvist d01759ec32 Typography round 3 (owner): session titles at the prose size
Titles were 14px (text-base); the user read them as still-small next to
the prose they open into. They now pull --lh-prose-size (16.5px) — the
same token lh-prose/lh-input render at, so the picker's titles and the
session's prose share one standard Source Sans 3 size (text-(length:…) —
the v4 var shorthand alone doesn't type-resolve). Meta stays 11px mono.
Live: title 16.5px vs meta 11px, row 54px. Gates: typecheck, lint, 469/469.
2026-09-17 13:29:21 +03:00
amosblomqvist 5e6d5f124c Typography round 2 (owner): lh-input (caret at the font-metric floor) + 14px session titles
- lh-input: lh-prose's 1.5 reading leading made typing carets tower
  (~25px box on 16.5px text, ~2.1× the glyphs — the caret fills the line
  box in Chromium). Inputs are not paragraphs: 1.25 leading drops the
  caret to ~21px, the font-metric floor (as close to the glyphs as
  Chromium will draw). Swapped on every typing surface — composer
  (chat + home), the thinking field, answer + editable-Other inputs, the
  model search; rendered prose (message.tsx container, option
  descriptions, chat.tsx pre-wrap) keeps lh-prose 1.5. Same family and
  size as prose; only leading differs.
- Session titles: 14px (text-base) — Source Sans at 12.5px read as
  small as the 11px mono meta under them; the title now holds its rank.
  Live: title "Source Sans 3" 14px vs meta "JetBrains Mono" 11px, row
  52px; composer line box 20.625px (was 24.75px).

Gates: typecheck, lint, 469/469.
2026-09-17 13:15:35 +03:00
amosblomqvist 1b7dbe2898 Typography round 1 (owner): the three-font rule enforced + Source Sans session titles
- The hidden fourth font: Tailwind's font-mono/font-sans utilities fall to
  the SYSTEM stacks (SFMono/Menlo — a font the app never chose), and
  streamdown's code-block chrome, mermaid-error, and the demo-card meta
  all use them — so every teaching-prose code block rendered in the
  system mono. Fixed at the token level: @theme now aliases --font-mono →
  JetBrains Mono and --font-sans → Source Sans 3, so every utility use
  resolves to the app's three fonts (chrome=JetBrains, prose=Source Sans,
  display=CMU bold).
- Session titles: Source Sans 3 (the human-words voice — the same font
  the words were typed in; mono = machine data stays on the meta line).
  One rule, finally applied to the picker too. CMU bold titles are the
  queued experiment if this reads wrong live.

Gates: typecheck, lint, 469/469. Live: title "Source Sans 3" 12.5px,
meta "JetBrains Mono" 11px; code blocks now JetBrains.
2026-09-17 13:13:26 +03:00
amosblomqvist 0f93124c7e feat: canonical world-scale renderer — sizes become world units + baked tuning + nice-number ladders
The world-scale pivot (2026-09-17): node/label/arrowhead sizes were
calibrated as "screen px at fit zoom", a unit whose meaning depended on
the graph's own extent — a 13-node graph at kFit 3.65 rendered 5.3x
smaller nodes than a 338-node graph at kFit 0.69, with no slider values
able to reproduce the dense feel. Sizes are now canonical WORLD units
(graph-canvas.ts drops the ÷kFit division in geometry/regions/hit-test);
kFit survives only as the label-gate 1x reference. The renderer's module
doc and the SIZE SEMANTICS block in render-settings.ts carry the story.

Camera becomes pure persisted view state: {k, x, y} stored to localStorage
(lh.graph.camera.v1, 250ms debounce, flush on unmount), restored on mount
via the new initialCamera constructor arg; the hardcoded fit() runs only
on first-ever open. A pin test locks the d3 zoomIdentity composition.

Region blur becomes world units too (the last span-coupled quantity): the
old bitmap-px reach scaled with graph span, so the 2026-09-14
search-filter collapse fix is now structural (blurW = tuning.blur; the
cellRef anchor is gone). Charge default bumps to -240 (single source:
CHARGE_STRENGTH in layout.ts; CHARGE_RANGE re-anchored so -240 sits
dead-center).

The 2026-09-17 bake — the user's live-tuned defaults (nodeSize 2,
edgeWidth 1, arrowLength 2, labelSize 3, labelZoom 4 / "8x", dilation 20,
blur 20, edgeAlpha 0.2, bitmap 256) — plus the ladder pivot: every
non-physics knob moves on a hand-authored 11-stop ladder (x1.5/x1.33
series, quarter/half steps at the low end), default dead-center at slider
position 50, step 10 = one stop per detent. The sanitizer snaps ANY
incoming value to the nearest stop (ties break low), so stored files can
never hold 2.18137...977-style artifacts; pre-pivot files (sizeUnits !=
"world") reset only the four resemanticized size fields. Physics sliders
keep the fine-grained log mapping.

The label cross-fade becomes PROPORTIONAL to the gate (span = gate x
LABELS_FADE_RATIO): the old absolute 0.5-ratio span read as a 100%
relative change at the old 0.5 gate but a 12% sliver at the tuned 8x —
"lower zoom levels naturally produce a longer transitionary period".
At the 8x default the node→cluster handoff now fades across one full
ladder stop (8x -> 12x). labelZoomDisplay is absolute (ratio / 0.5) so
the tuned gate keeps reading "8x" now that 4 IS the default.

Gates: 495/495 tests, tsc/eslint/build clean.
2026-09-17 12:29:17 +03:00
amosblomqvist 5ce78d0754 Merge lh-graph refinement wave (feat/edge-glyph @ 66e6212) into main
Edge glyph (⟹ follows arrow) WITHDRAWN by owner after two live-rejected
variants — CanvasEdge.type plumbing + ArrowGeom retained, differentiation
approach still open (docs/vision-edge-glyph.md decision record).

Keepers: regions.enabled "Background colors" compute off-switch (skips
splat/blur/readback/blit), clusterLabelSize (default 14, topmost draw
layer, screen-px) with Cluster labels toggle + size slider in the Regions
section, dependency graying across the settings panels, physics panel
reorder (general forces first, Depth anchor + conditionals last).

Merged cleanly over master's coverage-chrome changes in graph-view.tsx
(their gated fragment + the ungated GraphCoverageControl coexist); data/
scratch not carried (untracked, master's real render-settings.json picked
up clusterLabelSize=14 via sanitizer fallback). Gates 488/488; live-
verified: chrome intact, Background colors graying works, physics order,
coverage panel opens (11 sessions).
2026-09-17 09:48:41 +03:00
amosblomqvist 926ae2f43d Physics panel: general forces first, depth anchor block last
Presentation-only reorder (user 2026-09-17): the Depth anchor select
and its three gray-out-able sliders (Depth strength, Depth spacing,
Base ring radius) move below Friction to the very bottom of the
Physics section, so the always-active general forces come first and
the conditional group reads as a self-contained sub-section at the
end. No schema, wiring, or behavior changes — verified identical DOM
order live, gray-out still follows the stored depth mode.
2026-09-17 09:43:40 +03:00
amosblomqvist 3c5c48f502 Settings panel: gray out dependent controls when their parent is off
Dependency graying everywhere, consistent across sections (user
2026-09-17 — chose "gray out children" over hiding rows, so the layout
stays stable and the required-by relationship stays visible):

- Background colors off → Dilation, Blur, Edge alpha, Bitmap
  resolution dim (opacity-40) + lock (native disabled). It's a COMPUTE
  switch, so the whole region tune section follows it.
- Cluster labels off → Cluster label size dims + locks. Decoupled from
  Background colors (labels follow their own toggle, by design
  2026-09-16): label controls stay live while regions are off.
- Depth anchor off → Depth strength + Depth spacing dim + lock.
- Base ring radius is radial-only: dims + locks unless Depth anchor is
  Radial (vertical doesn't use a ring).

SettingSlider gains an optional `disabled` prop (row opacity-40 +
range input disabled); the two select rows dim + disable inline. Pure
UI — no schema or renderer changes.
2026-09-17 09:41:43 +03:00
amosblomqvist 2f4b9c82e4 Cluster labels: topmost layer + user-facing size setting
- Draw order: the cluster-label block moves from layer 5 to THE LAST
  layer of draw() — labels can never be occluded by node labels or
  hover names (user 2026-09-17; chose "topmost" after reassessing that
  discs were never actually on top — the white-out over discs is
  source-over compositing of the 85%-alpha text over near-white node
  fill, an optical contrast flip, not a draw-order bug).
- clusterLabelSize: new persisted setting (default 14 = the old
  REGION_LABEL_SIZE constant, so rendering is pixel-identical). Screen
  px at ANY zoom (drawn at size/k — camera-compensated, UI-like, same
  law as before). Log-slider range [7, 28] puts the default dead-center
  per the house slider law. Sanitized: old persisted files (no field)
  fall back to 14.
- Panel: the Cluster labels checkbox moves from the Labels section
  into Regions (user: cluster settings live with regions), joined by
  the new "Cluster label size" slider. Labels stay decoupled from the
  Background colors compute switch, as designed on 2026-09-16.
2026-09-17 09:35:11 +03:00
amosblomqvist b24bce3f83 Abandon velocity-weighted region opacity (owner-rejected, buggy at scale)
Per-node speed is invisible inside a cluster blob (still neighbors'
skeleton strokes dominate the local area), and per-cluster max-speed
couples everything: dragging ONE node dimmed the whole cluster's blob
to nothing — including disconnected members at near-zero velocity.
Neither granularity is right, so the idea is withdrawn entirely.

Manual revert of the velocity parts (a git revert of 154f6c8 would
have resurrected the frame fade): weight ramp, position stamps, EMA
state, motion-pending flush, per-edge stroke batching, and the tests.
Region splat is back to full-opacity coverage at rest AND in motion
(2026-09-14 no-stacking invariant). The regions.enabled compute off
switch stays.
2026-09-16 11:35:28 +03:00
amosblomqvist 74c14931af Velocity-weighted region opacity replaces the (sub-perceptual) frame fade
The linear prev→cur bitmap blend between rebuilds was verified working
but made no visible difference — the 3-frame bitmap cadence was already
below perceptual threshold for large blurred blobs; the churn during
fast drags is the SHAPE, not the cadence. Rolled back entirely (no
extra per-frame blits).

In its place, per the owner: while nodes move fast, their blob fades
out and fades back in on settling. Speed per node = screen px per
frame from position stamps between rebuilds, EMA-smoothed. The blob is
a CLUSTER attribute (still neighbors' skeleton strokes pass right
through a moving node's area — per-node weighting measurably did
nothing), so each cluster's weight = f(max member speed): linear 1→0
between 1.5 and 8 px/frame. Strokes and discs paint at that alpha —
before the blur + final edgeAlpha multiply, so coverage semantics are
unchanged at rest (all weights 1 = the 2026-09-14 no-stacking fix).

Stamps reset on data-object change, NOT geoVersion (the live sim bumps
geoVersion every frame — resetting on it zeroed all speeds; caught
live). regionMotionPending keeps rebuilding after the sim stops until
weights converge back to 1, so a blob never freezes at partial opacity.
2026-09-16 11:28:39 +03:00