Commit Graph
29 Commits
Author SHA1 Message Date
amosblomqvist 3d1ad38e46 ui: unify practice composer actions and node picker copy 2026-10-17 01:06:28 +03:00
amosblomqvist 487a63eaa5 ui: polish first-run setup copy and dialog dismissal 2026-10-17 00:38:57 +03:00
amosblomqvist e4207e869f setup flow: the dead dropdowns, save-never-sends, the pill hover
- The setup pickers opened invisible: React attaches a host element's ref
  AFTER descendants' layout effects, so MenuShell's mount-time place()
  measured a null anchor and gave up — the menu stayed hidden until a
  window resize re-ran place(). The stage-2 slot and thinking menus now
  anchor by anchorTestid (querySelector at place time, the settings-menu
  pattern) instead of the conditionally-attached triggerRef.
- Saving models NEVER auto-sends the queued message (owner ruling,
  2026-10-09): the draft waits in the composer and the learner sends it.
  The replay/draft-send calls removed from onDone in new-session, home,
  and chat; the dead force machinery removed from send/start/begin.
- The setup CTA pills get the app-wide hover language (hover:bg-accent +
  cursor-pointer) — hover:bg-muted was invisible because --muted and
  --card are the same #1c1c1c.
2026-10-09 04:54:24 +03:00
amosblomqvist 94470815ab keyboard: Tab cycles the surface's text fields, forward-only (keyboard 3 of 4)
Plain Tab now navigates instead of being pure dead weight: the global
handler dispatches lh:kbd-cycle-fields and each composer-bearing
surface answers with its own chain, gathered from live DOM at event
time (the docks mount and unmount — refs would go stale):

- chat: reading-thinking → the active dock's field → the composer,
  visual order, wrap; Tab from anywhere else focuses the first live
  field (navigate in)
- new-session + home: the composer is the whole chain — Tab navigates
  into it

Shift+Tab is untouched (the thinking-level cycle keeps it — owner
ruling); the quiz dock's rows↔thinking Tab claim stops propagation at
document level, so a claimed Tab never reaches the dispatch — no
double act.

1427 tests, tsc, lint, build green.
2026-10-07 00:04:03 +03:00
amosblomqvist fde858fda3 ui: the drafts go chrome-less; serif titles name sessions, always (owner round 11)
Both /chat/new drafts lose their TopBar — home's register now: drag
mask first in the DOM, floating sidebar-reopen toggle as the only
pointer-events-auto spot, the empty state carrying the orientation.
The chain picker's own bar then sits at the very top of the column,
exactly like the graph view — no stacked bars, no special-casing.
The chain table drops its 'Chains' title with it: the serif title
register is RESERVED — it always names a session; the table names
nothing, so its bar carries counts + search + close only.
2026-10-05 07:09:16 +03:00
amosblomqvist d0bb3da7b8 fix: the draft picker fills the content column, not the window
The overlay is absolute inset-0 — it resolves against its nearest
positioned ancestor. Mounted at the NewSession root it spanned the
whole window, over the sidebar and the traffic lights. It now hosts
inside the draft content column's relative container (the overlay
slot on DraftShell) — the same confinement the graph view gives it:
right of the sidebar, under the top bar, inside the session's section.
2026-10-05 06:56:59 +03:00
amosblomqvist 4f0e701d68 fix: the draft pick pill re-enables pointer events (the none-mask law)
The sticky composer wrapper is a pointer-events-none mask; the pill
row sat outside the composer box's re-enable, so it rendered dead —
no hover, no click. One class, the 953b22b pattern the CTA row and
composer box already follow.
2026-10-05 06:53:42 +03:00
amosblomqvist 93fdf71d4d draft: the pick bar comes to the new-practice screen (round 10 slice 2)
The SAME pill the home screen carries — above the composer, dashed
'Pick propositions from the map' when empty, count + Clear when picked
— now lives on the /chat/new?mode=practice draft. The draft has no
graph behind it, so the pill opens the standalone ChainTableOverlay
(select mode, controlled selection, Done bar) over the /api/graph
snapshot, fetched lazily on first open. The first send carries the
picks as graphContext: the created session is born WITH its ids-first
context marker (target validation's truth set) instead of pickless —
the same contract as the home screen's practice send. Teach drafts
render no pill; practice with no picks stays lean on the wire.
2026-10-05 06:51:23 +03:00
amosblomqvist e76e42f764 practice entry points: sidebar filter, practice draft, graph picker
- Sidebar: the three-way kind filter (Teaching/All/Practice, persisted
  in localStorage) above the search; two new-session doors (Teaching →
  /chat/new, Practice → /chat/new?mode=practice); practice rows carry
  the dumbbell badge and 'Practicing…' busy meta.
- Draft: ?mode=practice swaps the copy (title, empty state, 'What do
  you want to practice?') and startDraftSession posts kind:'practice'.
- Graph entry (home composer): teach|practice switch on the stats row's
  left edge (StatsBar leadingSlot); practice mode shows the propositions
  chip that opens the chain table as a picker (its floating bar becomes
  'Practice these' — no delete fence); the picks seed the opening prompt
  ('Propositions from the map:…') while the title stays the typed text.
2026-10-04 22:01:42 +03:00
amosblomqvist a99cbf3d90 dictation mechanics everywhere: graph view + new-session view
Consistency ruling (owner, 2026-10-01): the finish-first send and the
run-end focus are properties of the COMPOSER, not of one page.

- home (graph view, raw textarea): topicRef mirror; start() finishes
  dictation first (before the setup/key guards, so an intercepted draft
  is the complete one) then reads the mirror; the start square stays
  live while a run holds deferred speech; run end focuses the topic
  input.
- new-session: the shell owns the dictation instance — handleSend wraps
  the parent's send with finish-first, the composer stays sendable
  during a run, and run end focuses it via the Composer's inputRef. The
  parent's send reads a textRef mirror (applyText at every write): the
  flush lands via setState after the shell's await, and the parent's
  closure must not read a stale draft.
2026-10-02 01:42:55 +03:00
amosblomqvist 4fa6e2105a TopBar standardization + chain table round 2 (owner refinements, 2026-09-29 late)
- components/top-bar.tsx: THE shared top bar (standardization ruling) —
  h-15, lh-drag + carve-outs, select-none, SidebarToggle reopen with the
  Electron traffic-light gutter; adopted by chat (rename keeps its
  drag={false} escape hatch), new-session, practice-list, practice-exam,
  and the chains overlay.
- chain table: prominent indentation (pl-11 / pl-[4.75rem]), the ONLY
  lines are the separators between cluster groups (row + vertical lines
  gone), plain statement rows (no conclusion marker), all three columns
  sortable (Props too) with a small chevron on the active header.
2026-09-30 00:02:24 +03:00
amosblomqvist 3e9a85d60f per-session models: every session owns its model + thinking level from birth
The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of
any pick, while composer and settings UIs agreed with each other and lied
about the run. Root cause: a fossil default-settings record (settings.json
chat section, frozen pre-slots-wave) was stamped verbatim into every new
chat at birth by the new-session flow, and resolveConversationSpec prefers
the per-chat record over the strong slot forever.

Owner redesign — models are per session, always; the strong slot is the
new-session default:

- POST /api/chats stamps the newborn verbatim from the strong slot
  (model + thinking level); no client settings PUT on create anymore
- the composer control is dual-bound: in-session it reads/writes the
  session's own record (GET /api/chats/:id, PUT .../settings); on the
  draft surfaces it binds the strong slot (picking there = changing the
  default)
- thinking levels follow the identical mechanics (confirmed by owner)
- the zombie default-settings system dies whole: useDefaultSettings,
  /api/settings/defaults, src/store/default-settings.ts, the chat
  section of UserSettings (sanitizer strips fossil copies), the legacy
  default-settings.json fold, and DEFAULT_SETTINGS_FILE
- catalogDefaultsFor collapses to the strong slot alone
- thinking display for a legacy unstamped session resolves exactly like
  the engine (session ?? strong ?? model ladder)

Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot,
the unset level, and the copy-not-live-view regression; defaults pins
rewritten strong-only; legacy-migration suite deleted.
2026-09-28 23:30:11 +03:00
amosblomqvist 66090ed769 Pre-send key guards: no orphan error-shell chats on first send
The owner's asymmetry find (interlude over the zero-keys wave): the
models-unconfigured case guards BEFORE send — no request, no chat
created, setup opens — but the key cases only reacted to the 503 after
POST /api/chats had already created the record, leaving orphan error-shell
chats in the sidebar. The guards now twin the models mechanism on BOTH
creation paths (home start(), /chat/new send()):

- Zero keys (useZeroKeys reuse): no provider key present -> skip creation
  and send entirely, open SetupDialog (stage 1 first-key screen).
- Strong-provider key missing (owner-approved extension): keys exist
  generally but the strong slot's pinned provider has none — the original
  deleted-key scenario. New pure strongKeyless(catalog) in setup-cta.tsx:
  slots.strong resolved pin + providers[] (present-key only, the catalog's
  own federation surface), false when unconfigured (the models guard owns
  that case) or catalog not loaded (the server 503 gate remains the belt).
  useStrongKeyless rides useModelCatalog, re-reading on the 'keys' AND
  'models' signals (a deleted key or a re-pinned slot flips it live).

Both guards run after the configured check, same
skip-everything-and-open-setup treatment; 'force' (setup's onDone replay)
bypasses both — completing setup implies a keyed provider. /chat/[id]
sessions keep the 503 revert-draft interception unchanged: those chats
legitimately exist.

Pins: strongKeyless fires on a keyless strong pin (keys elsewhere or
none), never fires with the pin's key present (incl. the keyPresent=false
belt), false on unset pin / unloaded catalog. Normal-path pins ride the
existing startDraftSession contract suite. Live-verified on :3001 under
LH_IGNORE_ENV_KEYS=1 with a file-key anthropic + openrouter-pinned strong:
both guards on both pages (setup opens stage-1 / stage-2 respectively,
draft preserved, 154 chats unchanged, zero sidebar entries), normal path
with a keyed strong provider still creates and lands the prompt redirect.
929/929, tsc/lint/build green.
2026-09-25 00:46:10 +03:00
amosblomqvist 1f777f770c Zero-keys wave: provider-key gate renders honestly everywhere
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:

1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
   (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
   (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
   ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
   CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
   dispatches after save AND delete.

2. Setup stage 1 is self-contained: provider picker (MenuShell +
   ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
   STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
   re-runs on it, and the stage flips to the pickers on its own.
   onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.

3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
   parseModelsRefusal) joins the revert-draft-and-open-setup interception;
   activity-dock reads 'needs a provider key'; dispatch-card shows
   'Add a key' — both deep-link setup, which lands on stage 1 by itself.

4. Dictation trio: the token route's only 503 (missing Deepgram key)
   becomes the client-authored DICTATION_KEYLESS short line; the errored
   idle click clears the error and deep-links Settings -> Keys ->
   DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
   -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).

Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
2026-09-24 23:20:59 +03:00
amosblomqvist 8c50b50749 New-session page reads like a pre-session: no phase chip, outlined bar, opening-question placeholder
Three owner fixes for /chat/new (refinement round 4, 2026-09-24) —
the page must stop looking like a session that's already running:

1. NO PHASE CHIP: the header's <PhaseChip phase="probe" /> dies —
   no session has started, so no phase exists yet. Active sessions
   keep their chip (chat.tsx's derives from the live timeline).

2. TOP BAR STRUCTURE MATCHS SESSIONS: the header gains the same
   border-b border-border as the chat header (h-15, lh-drag,
   60px). The floating-bar look is gone; /chat/new now reads as
   the same shell with empty content.

3. COMPOSER PLACEHOLDER = THE OPENING QUESTION: active sessions
   keep "Ask, answer, or steer the lesson…" (now the DEFAULT of a
   new Composer `placeholder` prop), but the fresh page asks what
   the graph view's new-session input asks — "What do you want to
   learn?" No lesson is in flight, so the composer must not read
   like one is.

The ConversationEmptyState intro card ("The teacher will open by
probing…") stays, per owner confirmation.

Live-verified on :3001 /chat/new: no phase-chip node, computed
1px header border rgb(67,67,67) at h-15/60px, placeholder reads
"What do you want to learn?"; the empty-state card intact. Unit
test pins the default session placeholder AND the override.
903/903.
2026-09-24 19:21:03 +03:00
amosblomqvist 6b13583e96 wave 7: the prober reads its map — graph-context injection dies whole
The owner's zero-configuration ruling (2026-09-23): nothing configures
the graph beyond chat models. The prior-knowledge injection — its budget
dial, its runner, its marker part, its dock/dispatch entries — is
removed across all bands; the prober now reads the learner's persistent
knowledge map on demand through two gated tools instead:

- list_sessions: every birth session, names + kinds + counts + dates
  (graphSessionIndex, Platform's core seam)
- list_propositions(session): what was born there + the session's own
  latest understanding level + that evaluation's date
  (graphSessionPropositions) — names/levels/dating ONLY, no statement
  text (owner ruling: the map's organization is the prober's business,
  its prose is not)

Registered via createProberTools when chat-handler passes graphStore
(the graphToolsEnabled gate decided server-side, per-turn fresh); the
prober doctrine gains the map-review nudge at session start.

Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS
are plain records again), semantic-clusters.ts dies with its test, the
embedder dep's doc tells the wave-7 stub story, and ports.ts's
readClusterLabels/readNamingRuns surface stays untouched for Platform's
ledger-port interlude.

848/848 tests, typecheck/lint/build green.
2026-09-23 15:24:48 +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 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 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 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
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 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 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 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 4c7ad48c73 fix: the knowledge-context slider lives in the stats bar's left slot — the session-data spot — with a "tok" readout
The original vision, restored (owner, 2026-09-15): pre-chat the session-
data side of the under-composer stats bar is free real estate ("—" only),
so the budget slider mounts exactly where session data will sit once a
session exists — via a new StatsBar leftSlot — vertically centered with
the model + thinking pills by the row's own geometry. Short 96px slider
instead of a full-width one; readout abbreviates "1k tok" so >1k budgets
stay on one line ("4.1k tokens" wrapped). Home and /chat/new both.
2026-09-15 08:00:04 +03:00
amosblomqvist 1d45d77303 feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)
One shot per chat, fired at the first turn's onFinish: the session's
topic (its only typed message) is embedded as an EmbeddingGemma retrieval
query; the semantically closest graph propositions are selected under the
session's token budget (pure budget, no similarity threshold — the
decided doctrine) and delivered as a marker-only steer through the wake
pipeline, queuing behind pending questions like every sub-agent result.

- contracts: ChatSettings.graphContextTokens (default 1024, max 4096,
  step 128, 0 = off); GraphContextData + graphContextText; the
  graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>)
- core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill
  under budget, most-recent UnderstandingLevel fold, count cap 64,
  parse-don't-cast at the marker seam
- server: embedQuery (role-asymmetric query format, same warm singleton
  — vector-space parity by construction); the runner (startGraphContext-
  Injection one-shot gates; runGraphContextJob validates ledger parity);
  wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs
  re-deliver or contextRespawn); abortJob kills context jobs SILENTLY
  (nothing was promised in the lesson); turn-service trigger seam
- adapter: data-graph-context flattens to the canonical block (malformed
  parts have no model face)
- UI: the budget slider (home dock + /chat/new draft, the session-data
  strip); the quiet SystemLine "~537 · 10 propositions"; dock kind
  "Knowledge context"; model-picker PUT now spreads settings (a model
  pick can no longer clear the sibling override)

Live-smoked end to end on the real embedder + real GLM: seeded ledger
(10 propositions), session on "modal logic: possible worlds and the
necessity box" — job in the dock, queued behind the prober's question,
delivered on the answer, SystemLine in the transcript, wake turn reads
the block and continues the probe. Gates: tsc, lint, 459/459, build.
2026-09-15 07:54:50 +03:00
amosblomqvist 0f6e36f85f feat: the sessionless draft view — "+ New session" creates nothing
The sidebar button no longer POSTs a record (no more "Untitled session"
litter): it opens /chat/new, a sessionless draft view visually identical
to a fresh chat (same header + probe chip, the same ConversationEmptyState,
the same composer blob + stats dock). The session comes to exist only
when the learner sends the first message — and that message becomes its
title (startDraftSession: POST /api/chats {title: msg} → PUT the lifted
pre-chat settings → redirect /chat/<id>?prompt=<msg>, whose initialPrompt
path delivers the message as turn 1 — the home composer's proven contract).

- app/chat/new/page.tsx: the static draft route (static beats [id]).
- components/new-session.tsx: DraftShell (pure render seam) + NewSession
  (router wiring); the model picker's empty-chatId branch defers its save
  and lifts the selection, so pre-chat model/thinking rides the creation.
- components/sidebar.tsx: newSession is a plain router.push("/chat/new").
- tests/render-new-session.test.tsx: 6 pins — the create-on-send contract
  (title = message, settings PUT, no-op on empty text) + the shell render.

Live-verified end to end: button → draft (0 creations), pre-chat Qwen
pick, send → exactly one session titled with the message, settings
persisted, redirect + turn 1 running. Gates 431/431.
2026-09-14 09:06:48 +03:00