d6d7c09e2e69ddf144fa28fb92876522118135e5
296
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d680008f25 |
fix: teach divider prepended, not a replacement branch — first lesson block renders
The inferred teach seam sat as a ternary branch in the messages.map chain (alongside the no-bubble marker branches), so the ENTIRE first teaching group — thinking, prose, the tagged quiz call — rendered AS the divider and vanished below it (the Lacan session, 2026-09-27). The marker branches replace contentless scaffolding; the teach group is a real message. The divider now prepends inside the group's Fragment. Also tightens 10-planning.md: the presenting turn asks NOTHING — no quiz, no ask_user_question, no 'shall we begin?' (the teacher was substituting a confirmation question for the dead verdict buttons). |
||
|
|
145b470752 |
fix: verdict death — plan→teach seam inferred from first nodeId-tagged quiz
The Accept/Decline verdict gate is gone (owner ruling 2026-09-26): presenting the plan ends the teacher's turn; the learner's next message is a natural reply. Teaching begins at the first quiz call tagged with a nodeId (probe quizzes carry none) — both folds (view phaseAt3, native phaseAt3Lh) advance on the call itself, and the teach divider renders before that whole assistant block. No backwards compat: verdict markers, the verdict input event, optimistic appends, composer buttons, and projection all deleted; prompts and docs rewritten to the natural flow. |
||
|
|
dcb839232c |
fix: plan graph visuals — reading font, 1px lines, both-ends arrow color
Owner's three rendering refinements (plan-graph round, 2026-09-26): 1. Reading font: the diagram root sets font-family var(--font-prose) — node titles render in the reading font instead of the inherited mono UI font. Deliberately NOT the font-prose utility: @theme inline bakes the literal into utilities (the --font-title trap), which would freeze the graph at Source Sans 3 and ignore the Appearance reading-font override. The runtime var rides it (live-verified: Georgia override propagates, null resets to Source Sans 3). 2. Thinner lines: edges + box outlines 1.5 → 1 (the app's standard border weight). The arrowheads scale by the same 2/3 (HEAD_H 16 → 10.67; the width, the notch overhang, and the DOCK_GAP all derive from the head scale), so the head:shaft LaTeX proportion is preserved exactly. 3. Arrow color: white only when BOTH ends are white — an edge with an upcoming origin OR upcoming terminal stays gray (line + head). Supersedes round 6's target-only rule: arrows leaving a gray box no longer paint white into covered territory. Live-verified on :3001 (native-format fixture: plan_init + answered quizzes; popup): font default + override, stroke-width 1 on all rects and edges, 5-edge graph with exactly one both-white edge white and both upcoming→visited cross edges gray. |
||
|
|
e9b9f52a7e |
fix: question dock flicker — arrival state at render, growth-stamped fold mute
Two owner-reported symptoms, one mechanism (verified frame-by-frame on a zero-model-call fixture): the position machine read its OWN growth-driven scroll lag as the learner leaving. 1. Probe arrival at the bottom: the form opened, the reserve grew, the smooth catch-up lagged (d peaked ~405px > COLLAPSE_DIST 96) → phantom fold → bar shrink morph → re-expand at bottom. FORM→BAR→FORM, ~600ms. Fix: the at-bottom arrival now runs under the SAME fold suppression the teach reveal uses (suppressFoldDuringReveal, extracted) — the arrival's open and its scroll-to-bottom are the same motion class. 2. Probe arrival scrolled up: one stale-open frame painted before the after-paint effect collapsed it. Fix: probeArrivalExpanded — the open/closed state decided DURING RENDER (open iff at bottom), like the teach seam; the effect keeps only the reveal scroll. 3. Dictation stop flush into the pending question: the field grows on its own render (capped 192px), the reserve lands on the NEXT DockOverlay render (~1s later live), and the spacer's growth spiked d≈152 → fold → bar → re-expand. Fix: the offer stamps the geometry mute when the reserve GROWS while pending — the spacer is the phantom-scroll source, so the mute covers exactly its scroll events. Shrinks never stamp (the resize round's stuck-collapsed regression). Live-verified on :3001 (job-settle fixture, abort-channel settle, no model calls): A at-bottom arrival FORM-only; B scrolled-up arrival straight to BAR with zero FORM frames and no scroll-down; C dictation flush FORM-only through the +152px reserve growth. |
||
|
|
9853075331 |
fix: quiet Retrying lines, quiz option descriptions nuked, dividers at the markers
Three owner rulings from the gradient-descent session
(chat_muhskq6p_e8c3600b — 7 failed quiz calls, answers leaked in
option descriptions, planning divider floating at the transcript top):
1. RETRYING LINES (general): any tool call that errors back to the
agent (validation failure, malformed args — the retry loop) renders
one quiet "Retrying…" SystemLine instead of silence. Question tools
previously rendered null, so a flailing quiz loop read as a stall.
Aborts are terminal ("Operation aborted") and stay silent. Verified
live: exactly 7 lines render at the 7 failure sites of the session.
2. QUIZ OPTION DESCRIPTIONS: NUKED. The model used them to telegraph
correctness in every quiz of the session ("that moves uphill — it
climbs f instead of descending it") — answerable by elimination.
The field is gone from BOTH quiz schemas (probe + teach); ask keeps
it (ungraded — nothing to leak). The dock additionally suppresses
descriptions for quiz kind, so pre-fix records stop leaking too.
Verified live: the session's pending quiz renders bare labels.
Plus the diagnosed failure-mode fixes: GLM sometimes emits `options`
as a JSON-encoded string (6 of 7 failures). prepareArguments hooks
on all three question tools coerce it BEFORE pi's validation (the
call lands instead of bouncing); the client coerces again in
normalizeOptions (pi persists raw call args). Schema descriptions
hardened ("REQUIRED ... never a JSON-encoded string").
3. DIVIDERS AT THE MARKERS: the probe-handoff SystemLine
("Preparing your plan…") IS NOW the Planning divider; the accepted
verdict IS NOW the Teaching divider ("✓ Plan accepted" line gone) —
one signal per seam, not two. The fold-based divider block is
DELETED: Wave D grouping merged the whole probe into one giant
assistant group, so the inclusive-fold comparison floated the
divider to the transcript top (reproduced against the real record:
6 view messages, phase flipped at the giant group). The markers
render in the right spot by record construction. A DECLINED verdict
is not a seam — it keeps the "↻ Plan revision requested" line.
Verified live: plan divider after the probe concluded, teach
divider after the plan presentation, nothing at the top.
Tests: 1004/1004 — string-coercion pins (normalizeOptions + the
prepareArguments hooks on all three tools, identity fast path),
quiz-schema-has-no-description pin (ask keeps it).
|
||
|
|
7f51259fc6 |
feat: Wave C - pi-native steer/follow-up delivery
Delivery now rides pi's OWN follow-up queue when a run is live, and a carry-everything idle wake otherwise. One registry, two vehicles; claimSteers and continuation coalescing are deleted (owner rulings, 2026-09-26: follow-ups only; one queue; the wake is the only appender). THE LIVE RUN (vehicle 1): the chat-handler registers the streaming turn's agent (registerLiveAgent, next to registerTurn); the pump hands every eligible queued marker to agent.followUp() - pi polls that queue when the run would otherwise stop, injects the markers (message_start/message_end on the live wire), and CONTINUES the same run. followUpMode "all" joins every handed marker in one continuation turn. The question stopper runs before the follow-up poll, so an open quiz is never overridden - a handed marker strands at settlement and waits (today's pacing). probe-handoffs never take this path (the handoff must START the teacher's run - a record-level branch). SETTLEMENT RECONCILIATION: settleSteers(chatId, persisted) at onFinish (after unregisterLiveAgent) resolves+flips entries the run delivered and returns stranded ones to pending - the registry stays the truth; the dead agent's queued copy is abandoned. This is what makes the handoff safe: pi owns no retry for a stranded follow-up. THE IDLE WAKE (vehicle 2): waitForIdle (unchanged gates) then ONE turn carrying EVERY pending marker as its input appends - dequeue-at-fire (the Wave B regression lesson: the wake run's own follow-up poll must never see its own cargo; a 409 pushes the cargo back and retries). CLIENT: applyWireEvent now upserts non-assistant messages by id, so in-run injected markers render live instead of dropping until settle. Tests: jobs 44/44 (live handoff, settlement stranding, handoff-waits, one-wake-carries-all); chat-flow 23/23 (the double-append pin drives the real pump through the new input); session-core 28/28 (custom-role wire appends). Full suite 997/997, tsc/lint/build green. Live smoke (PORT=3004): research job delivered mid-probe - parked behind the dangling questions exactly per semantics, landed once on the open gates, teacher folded the brief in and re-dispatched; the second (good) result rendered the full 10-source brief card and the lesson continued from it. No duplicate ids anywhere in the record. Known gap flagged to owner (NOT from this change): the server accepts malformed answer events via raw API (answers without the answers[] shape) and AnsweredCard then crashes the page render - surfaced by the smoke's scripted answers, repaired by hand in the scratch record. |
||
|
|
8e097752da |
fix: streaming appended a message per chunk - track the in-flight slot
pi streams AssistantMessages with NO id (verified against a captured live stream and pi-ai's AssistantMessage type): the id exists only once the server's persistTurn stamps the record. applyWireEvent minted a FRESH c_ id per event, so message_start appended, every message_update found no match and APPENDED another full copy, and message_end appended again - the tail grew one message per chunk. projectView's step separators + thinking blocks then filled the transcript with dividers and thinking indicators mid-stream (with fresh React keys remounting the whole subtree per chunk), cleaned up only by the settle sync's setMessages(record). The fix: the in-flight assistant message is tracked by SLOT - message_start mints and remembers one client id; message_update and message_end REPLACE that same tail entry (pi guarantees exactly one streaming message at a time via its strict start-updates-end ordering; the reattach replay replays the same sequence). An explicit message.id on the wire still wins; the slot clears at message_end, stop(), and setMessages(). Tests: all streaming fixtures rewritten to pi's real shape (no message.id) - the old ones encoded the wrong assumption - plus a regression pin asserting N id-less updates leave exactly ONE assistant message in the tail with a stable slot id. Verified live mid-stream (DOM sampled every 500ms during a teaching turn): 0 separators throughout, thinking blocks 1-2 (one per step, not per chunk), text growing smoothly. |
||
|
|
0803fbd88d |
fix: the empty-new-session bug — three live client defects
Live-driven via the owner's dev server (browser tools), reproduced on /chat/new: send fires, optimistic appends, POST never happens, and the page shows the frozen empty state. Three compounding defects: 1. session-core: default fetch stored UNBOUND — the browser's native fetch requires this === Window, so every call threw 'Illegal invocation' and the POST /api/chat never fired. Node's undici has no receiver discipline, which is why 994/994 tests passed while the app was dead. Now defaulted through a receiver-preserving wrapper; pinned by a strict-this regression test (the browser's semantics simulated. 2. session.ts: state memo keyed on [model, forceRender] — the reducer dispatch is STABLE by contract, so the memo computed once per mount: no optimistic bubble, no stream events, no error banner. The view was frozen at first render. Rekeyed on a version counter bumped by the model's onChange. 3. view.ts: turn_end carries the clean 'Question posed' placeholder as a toolResult message (details: null) — applyResult flipped the quiz part to output-available with a null output and AnsweredCard crashed on answer.dismissed (killed the whole page render). Fork 2 now holds on the message-attachment path too: clean question results with null details are ignored; the AnsweredCard renders only the learner's real answer. Regression pin extended in session-core.test.ts. Verified live end to end: new session → text send → probe questions in the dock → ask answer → graded ✓ quiz card with explanation → reload renders the full transcript from the native record with the pending question intact. EOF ) |
||
|
|
f3003d357a |
Wave D: native AgentEvent wire + client session rewrite (SDK purged)
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record). |
||
|
|
2e8bf92408 |
fix(answer): stash the answer input event before addToolOutput
The onSubmitAnswer path was the one Wave B send site missing its stash: addToolOutput triggers the SDK's automatic continuation send, the transport found nothing stashed, and threw — the answer never POSTed, so the question stayed open. One line (plus the contract comment). Caught live by the owner; the suite drives answer events directly at the handler, so this client seam stays untested until Wave D's client work. |
||
|
|
7ab47d4fcb |
Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
|
||
|
|
ac1c1a2818 |
Font picker fix round: one search field, honest empty-enumeration fallback, tooltip only on the closed trigger
- FontMenuList: the separate custom-family input is gone; the search field absorbed its job — a no-match query renders a 'Use <query>' action row (customFamilyFor, pure, pinned), Enter picks it - openFontMenu: an empty-but-successful queryLocalFonts() now falls back to the curated list (Electron 44 grants local-fonts and still enumerates zero fonts — reproduced with a hidden-Window probe); the footer split states WHY: 'could not be read' vs 'needs the desktop app' - The Hint tooltip wraps the trigger button only and unmounts while the menu is open — it no longer pops over the dropdown |
||
|
|
ad61301324 |
Appearance wave: searchable reading-font dropdown + emphasis inks default to text color
Two owner rulings (2026-09-26):
READING FONT — the free-text CommitInput is retired; a searchable
dropdown of the user's INSTALLED fonts over model-menu's exact
fuzzy + arrow-walk + Enter machine (FontMenuList in model-menu.tsx,
fed by window.queryLocalFonts in the desktop shell):
- The DEFAULT row (Source Sans 3) is a fuzzy CANDIDATE, not a ghost:
fontMenuResults (pure, pinned) matches it on empty/source/sans/
default queries only, so an unfiltered list opens with the default
first and a searched list drops it honestly — Enter can never pick
a row that isn't on screen. Picking it writes fontFamily null
(empty = Source Sans 3 semantics preserved).
- The enumeration is CLICK-bound (queryLocalFonts requires user
activation — it cannot be a mount effect), deduped to unique
families, the factory family excluded (the DEFAULT row is it),
cached for the section's life; a failed enumeration falls to the
curated list rather than an empty picker.
- Dev-browser fallback is honest, never fabricated: queryLocalFonts
absent → a small curated list (the app's own knowns + ubiquitous
macOS families) with the footer 'Common families — your installed
list needs the desktop app'. The manual escape lives inside the
menu: a custom-family input + Set commits any family name the
enumeration didn't surface (the old free-text input's heir).
- composeFontFamily (pure, pinned): null/blank → null, else verbatim.
- The trigger gets a Hint (tooltip wave convention); the row's
empty-state hint keeps reading 'Empty = Source Sans 3'.
⚠ electron/main.cjs — one line, FLAGGED FOR PLATFORM: 'local-fonts'
joined the allowedPermissions set (the Local Font Access grant the
picker needs in the desktop shell; the loopback-origin discipline is
untouched).
EMPHASIS INKS — the factory #ee928c/#6fa9d6 are gone: both globals.css
theme blocks now default --lh-ink-strong/--lh-ink-italic to
var(--foreground) — unset emphasis renders the regular text color.
Explicit hex overrides ride unchanged (the appearance seam was already
null-honest); the rows' empty-state labels now say 'same as text'.
Tests (+16, 983 total): fontMenuResults default-row candidacy +
filtering, composeFontFamily null/blank/verbatim, the curated roster
(no factory-family duplicate), FontMenuList SSR shape (default row,
families, effective highlight, custom escape, loading state), the
trigger-replaces-input pin, and the globals.css factory pin (no old
hexes in the declarations; var(--foreground) ×2 in both blocks).
Live-verified on :3001 (dev browser — the curated-fallback path):
dropdown opens with the default row + curated list + honest footer;
search filters ('serif' → CMU Serif only); picking Georgia lands
record + --font-prose live; the default row unsets (var cleared);
the custom input commits Baskerville verbatim; Esc closes the menu
WITHOUT closing the dialog. Ink: strong/em computed = rgb(230,230,230)
(the text color) at factory; #ff0000 through the dialog renders red
and clears back cleanly. The desktop queryLocalFonts path cannot run
in this environment — the seam is click-bound + cached with the
failure fallback, and needs one desktop-shell smoke.
|
||
|
|
3dfc331d7b |
Prompt editor affordances: version-in-use hint, dirty-gated Save, auto-on
Owner-confirmed semantics (verified in composeInstructions first: switch ON = the user's override text is what the agent reads; OFF = the default composes, the saved file stays on disk unused). Four refinements: 1. The small text under a file name states WHICH VERSION IS IN USE: 'Your version is in use' (PROMPT_IN_USE_HINT, pinned) when the switch is on; NOTHING when the default composes — even with a saved override sitting on disk. The old presence-based 'Your version is saved' (shown whenever a file existed, regardless of the switch) dies; the switch's meaning becomes legible through the line it visibly controls. 2. Save is clickable only when there's something to save: grayed and inert while the draft equals what's on disk (including the untouched editor just opened), full-contrast the moment the text differs. The always-active same-color button was the 'doesn't feel like it does anything' half of the report. 3. Saving auto-turns the switch ON — writing an override and putting it to work collapse into one action (a save with the switch off was the did-something-changed-nothing trap). Fires whenever the record genuinely has the path off, so a re-save after toggling off re-on's too. Order is the safe one (file first, then the switch the validation requires it). 4. A quiet inline 'Saved' / 'Deleted' confirmation rides next to the buttons (the old status line echoed the file path at the section's bottom — production-copy violation and easy to miss). Plus the mirror-symmetry the auto-on needs: deleting an override while its switch is on now composes the switch OFF in the same action — not just redundant with Platform's 643a693 server-side clear: the dialog's record mirror is what composes the next section write, so a stale ON would silently defeat the auto-on after a save-following-delete (the mirror would think the switch is already on and skip the write the server record needs). Live-verified on :3001: untouched editor grays Save; typing activates it; reverting re-grays it; save-with-switch-off lands toggle ON + hint + 'Saved' + Save re-grayed in one click; toggle-off drops the hint with the file retained on disk; re-save while off re-on's; delete while on clears switch + file + hint with record left clean and a subsequent agent-section write landing 200. |
||
|
|
178995c987 |
Agent prompts section: re-read on write, dedupe, owner-ruled layout
The prompts area's five owner-reported symptoms, root causes found in code and verified live before fixing: 1. STATE ECHO — the toggle's truth (SegmentInfo.enabled) derives from the settings record's agent switches, but the segment view was read ONCE per section mount: the switch write landed (PUT /api/settings/agent) yet the toggle painted its old state until a remount re-fetched. Root-cause fix in the settings-saved family: the section now re-reads /api/settings/prompts when the 'lh:settings-saved' signal lands for the agent section — the reader the signal law was missing. Toggle flips verify at ~40ms, no navigation. 2. DUPLICATION — the server contract returns the shared segments in BOTH per-agent lists; the UI rendered both lists back to back, so every shared chunk appeared twice (18 rows, 5 dupes). sectionedSegments (pure, exported, pinned) dedupes: the agents' own chunks plus the shared chunks ONCE. 3. ORDERING — Teacher first, then Prober, then Shared last (owner-ruled), each section headed, chunk order within a section preserved from assembly order (the server lists ARE assembly order: base, shared sorted, own numeric-prefix sorted). 4. GRAYING — the whole-row dim died; only the TOGGLE dims now when there is no override file (a wrapper span on the control; the file name is always full-contrast). 5. DESCRIPTION LOGIC — the 'No changes yet' line died. No description when there's no override (the empty state is clean: toggle + name); 'Your version is saved' (PROMPT_SAVED_HINT, pinned) appears ONLY when an override exists. The redundant 'Shared base — appears in both agents' editor note died too — the Shared section header carries 'Used by both agents'. Also: saveSegment/deleteSegment reuse the readPrompts callback (keeps last-good on a failed re-read; the mount effect owns the loading state). Live-verified on :3001: sections in the ruled order with 13 unique rows, clean-row and overridden-row states per the rulings, toggle flip instant (view + record agree), save/delete round-trips, and a shared-segment save + toggle through the single Shared control. Open finding (NOT fixed here — needs a ruling): an ON switch whose override file is later deleted orphans the record, and the agent-section write validation 400s every subsequent write (an ON switch must have an existing file) while the UI's disabled toggle cannot reach the OFF escape hatch the validation anticipates. Repro: Save + switch on, Delete the override, flip any tool. Server validation + row semantics are Platform's / owner's call. |
||
|
|
79e7c8648b |
Stream reattachment client half: resume via same rendering path
Reattach to a turn that outlived its view through the SDK's native
resumeStream() so replay burst + live deltas flow through the exact
same processUIMessageStream chunk pipeline as first-subscriber
streaming — no second rendering dialect.
chat-transport.ts: ReattachingChatTransport subclasses
DefaultChatTransport and repoints reconnectToStream at GET
/api/chats/[id]/stream (the server's reattach endpoint, not the SDK
default /api/chat/{id}/stream). It peeks the first SSE event:
- turn-settled terminal: cancel body, fire onSettled (refetch), return
null — the SDK treats null as no active stream, status stays ready.
- start chunk whose messageId equals the seeded last assistant message
id (continuation case): rewritten to a fresh id so the resumed run
mounts beside the seeded message instead of replacing its parts —
settle-time record sync merges to the server's assembled message.
chat.tsx: mount effect resumes when initiallyInFlight (no once-ref:
React StrictMode's dev double-mount aborts the first attempt at
dispose — the retry must run). Mid-session discovery sites (jobs
poll, handoff watch) funnel into attachToRunningTurn().
turnInFlight is demoted to fallback liveness: the 1500ms record poll
self-heals a failed attach and clears a stale error banner on settle.
A status watcher clears the flag once the local stream represents
the turn. Stop/Esc unified in abortInFlightTurn: local stop() when a
stream runs here, always POST /api/activity/abort {kind:'turn'} with
409 not_running treated as a benign raced settle. Composer busy now
covers reattached turns (Stop square shows, send gated).
Live-verified on :3001 with real model turns: navigate away mid-turn
and back (replay + live deltas in-view, fallback poll disengaged),
Esc-abort of a reattached turn (partial persisted server-side,
composer restored), two concurrent sessions (raced-settle handled
via turn-settled + record refetch; live settle adopts the final
record), and no leaked stream readers after unmount.
|
||
|
|
5f5fa32482 |
Teach questions arrive collapsed in their first paint — no phantom scroll
Owner report (2026-09-25): in the teaching phase a new question arrives collapsed behind the reveal button, but the transcript scrolled up as if it had OPENED — the room it made was the expanded form's height, never shown. Root cause: the expanded state is stale-true from the previous question (nothing resets it when the dock returns to the composer), so the new question's FIRST paint rendered open; the dock measured itself tall and offered that height to the spacer, and the collapse one tick later could never give it back (fold immunity refuses drops while a question pends). Collapsed bar, tall reserve — scroll as-if-opened. Fix (owner-shaped, deliberately minimal): apply the arrival state DURING RENDER, before the first paint (the render-time state adjustment pattern) — revealArrival(wasQuestion) decides how a teach question lands: after the composer → collapsed (the learner reads the teaching first); after ANOTHER question (a queued parallel check) → OPEN — the reading already happened, and the height it already has is the honest height. The paint measures the truth, so the reserve never learns a height that isn't on screen. Probe arrivals are untouched — instant-open at the bottom stays. The old [pendingId] effect keeps only its probe branch. Live-verified on :3001 with a fabricated teach-phase session driven through the real settle/adoption path (zero model calls): composer→teach arrival lands as the 59px bar with the spacer growing 206→211 (+5) and the transcript scrolling only 77px (the new message itself) — the previous behavior jumped the reserve +339px and scrolled 660px; teach-check reveal click still expands (425px) and scrolls to bottom; a queued parallel question arrives open with the reserve unchanged (577→577) and +40px scroll. Pins: tests/reveal-arrival.test.ts — revealArrival(false) = collapsed, revealArrival(true) = open (the owner ruling). 946/946. |
||
|
|
b3c07f89f4 |
Fix ⌘B: dispatch/listen event-name mismatch; thinking pill → "Thinking (⇧⇥)"
Root cause (interlude, owner 2026-09-25): keyboard-shortcuts dispatches
registry actions on the lh:kbd-<action> convention, but the sidebar's
listener sat on the pre-convention name "lh:toggle-sidebar" — a name
nothing ever dispatches, so ⌘B silently did nothing while its tooltip
and cheat-sheet row kept claiming the binding. A sweep of every lh:
listener confirmed the sidebar was the lone stray (chat-renamed and
graph-revision are deliberate non-kbd events).
Fix, structural: kbdEventForAction(action) exported from
src/core/keyboard.ts — the ONE spelling — and both sides compose
through it (dispatcher's default branch, sidebar's toggle listener);
keyboard-shortcuts re-exports the helper so the contract pin can assert
no second spelling exists. The mismatch is now impossible by
construction.
Thinking pill: label trimmed to "Thinking" with its real shortcut via
the registry-composition path — "Thinking (⇧⇥)" (cycle-thinking,
rebind-live like every Hint suffix).
Pins (tests/keyboard-toggle-sidebar.test.ts): the full chain — mod+b →
actionForBinding → toggle-sidebar → kbdEventForAction →
lh:kbd-toggle-sidebar; formatBinding("shift+tab") → ⇧⇥; the
dispatcher-imports-the-same-helper contract.
Live-verified on :3001: ⌘B visually toggles on home, /chat/[id], and
/chat/new (sidebar 0↔288px, hamburger slot 0↔36); with the rebind
lh:keymap {"toggle-sidebar":"mod+j"}: ⌘J toggles and ⌘B is inert
(rebind-live); thinking pill renders "Thinking (⇧⇥)"; keymap restored,
sidebar left open.
|
||
|
|
6b83d3248b |
Tooltip subtlety round: 500ms rest delay, quiet chip, parens suffixes, drops + trims
Owner round 2026-09-25, over 09c0f6e. The wave's tooltips read as distracting; this round makes them quiet: - Delay: TOOLTIP_DELAY_MS = 500 in the primitive — tooltips appear when you pause on a control, never in passing (Radix focus-opens stay instant by design; keyboard users don't wait). - Vanish on leave: disableHoverableContent — the chip is un-hoverable, so the instant the cursor leaves the trigger it's gone, even mid-move onto the chip's own pixels. - Look: bg-accent (#2a2a2a, the hover-chip tone) instead of the white bg-foreground slab; 11px; px-2 py-1; arrow culled (nothing purely decorative); text-foreground/85. - Suffix: parentheses, not em-dash — "Dictate (⌘D)"; the registry source unchanged (still override-aware). - Drops (owner-selected): send square, start square, stats summary — the icons/numbers describe themselves. - Home logo kept but to the RIGHT with its real shortcut "Home (⌘G)" (jump-graph routes to /, the same destination). - Trims: "Model selector (⌘K)", "Thinking level", "Graph costs — click for details", "How much of this session isn't mapped yet". - Added: Import Notes gets "Import notes into the graph" — the one button whose destination wasn't obvious. Pins: TOOLTIP_DELAY_MS=500; hintText parens expectations updated. Live-verified on :3001 — 508ms hover delay measured, close within 10ms of pointerout, chip styles probed (rgb(42,42,42), 11px, 4px/8px), arrow gone, all new labels + sides confirmed, drops carry no tooltip and no aria-describedby. |
||
|
|
b19e7e7eae |
Production tooltips: styled tooltips everywhere, shortcuts surfaced, dev-speak culled
Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill controls ride the styled Radix tooltip via a new Hint composition (components/ui/hint.tsx) — the shared primitive adopted as-is, no restyling; the shortcut appends as an em-dash suffix off the LIVE registry (a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D". Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop. Native title= stays only for truncation-reveals (dock spans, the rename field) and menu-interior abbreviations (MenuShell untouched). One em-dash pattern; per-state truth on the send square (busy = "Stop the reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept, "Stop dictation (Esc)" kept verbatim per the owner's flag. Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx (hintText composition, asChild no-wrapper-DOM, the auto-update hint constant); dock truncation titles pinned native; graph-chrome + settings pins updated to the new copy. Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag); glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵, Esc); per-page probes confirm Settings entry, Home logo, session search, Start session, Close sidebar, model + thinking pills, stats summary, rename title, plan chip, coverage ring, send square, cost button, node search (⌘F), graph gear, settings close (Close — Esc), keyboard section "Fixed — part of this screen", auto-update hint, light-mode "Coming soon", the three slot rosters, graph-tools hint. Esc still closes the settings dialog. |
||
|
|
81be55f35c |
Empty-graph chrome: Import Notes stays, search + settings wait for nodes
Owner ruling on the empty graph (honesty principle — dead controls are lies): the top-chrome actions row now gates per control. - Import Notes renders ALWAYS (graph enabled) — the one action that works on an empty graph, the entry point itself. It used to hide with everything else, leaving the empty state a dead end with no entry. - Search + settings render only when the snapshot has nodes, and their appear-once mount carries a deliberate 200ms fade (graph-chrome-fade-in, globals.css — the settings-pop register, reduced-motion appears instantly). Computed animationName verified live. - The row's body is now the exported pure GraphChromeRow (the DeleteNodeBody pattern) — the no-match note's truth (visibleIds + snapshot) stays upstream, computed by the wrapper. - The empty-state placeholder copy is LEFT AS-IS (SCOPE rule: it already has copy — icon, title, and the session-mapping paragraph). Pins (tests/render-graph-chrome.test.tsx): empty → Import Notes present, search/settings absent, no fade carriers; populated → all three, exactly two fade carriers; the no-match note follows its upstream truth. SSR of GraphChromeRow both ways. Live-verified on :3001 with scratch data: empty graph → Import Notes only + placeholder intact; a real import through the button (file input via DataTransfer) → nodes land, search + settings fade in (computed chrome-fade-in on both carriers); wipe → reload → back to Import Notes only (the reverse path). Scratch graph restored byte-identical. 932/932, tsc/lint/build green. |
||
|
|
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. |
||
|
|
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. |
||
|
|
6896c23cc2 |
Session deletion dialog matches the node deletion dialog: red title, mono name below, checkbox flow
Owner polish round (2026-09-24): the two destructive-confirmation
surfaces must read as ONE design. The node dialog was the good one —
the session dialog now wears its exact look and flow.
components/delete-session-dialog.tsx (full rework):
- Red serif "Delete session?" (was neutral "Delete "title"?") — the
node dialog's destructive title, text-destructive on font-title 2xl.
- The session name below the title in JetBrains Mono at text-lg,
NO code-block chrome — the font already says code. (The node
dialog's name got the same treatment: text-xs bg-muted code block
dies, mono text-lg line lives. Owner chose below-the-title over
parens-in-the-title: long titles wrap safely below, never inside
the 25px headline.)
- TWO OPTION BUTTONS -> CHECKBOX FLOW: the locked two-button design
(preserve-graph / delete-nodes) collapses to ONE unchecked-by-default
checkbox "Also remove its graph traces" (accent-primary, the settings
panel's native-checkbox idiom) carrying the graph-touching option's
consequence copy verbatim. Unchecked = preserve-graph; checked =
delete-nodes. The unchecked promise ("everything it added to the
graph stays unless you check below") lives in the intro line now.
- Bottom row = the node dialog's exact pair: quiet Cancel + destructive
red Delete (border + text, fill on hover), "Deleting…" on the Delete
button while busy. Try-again re-derives the mode from the checkbox
(retry IS Delete pressed again; the checkbox is disabled while busy).
- DeleteSessionBody exported pure (the settings/node dialog body
pattern) for SSR pins; TRACES_COPY exported for the copy pin.
DELETE_OPTIONS dies with the two-button design.
Tests: session-context.test.tsx's two-option pins rewritten for the
node-dialog shape (red title, mono name no-chrome, unchecked checkbox
default, red Delete pair, old testids absent); delete-node-dialog
pins the chrome-less text-lg name. 905/905. Live-verified on :3001:
both dialogs render 25px red serif titles, 16px mono names, matching
red Delete buttons; Esc closes both, nothing deleted in verification.
|
||
|
|
73027f49bf |
Cost channel cull: the cluster-naming fold dies — two stages, two channels
Off UI's d651f93 flag (displayCostTotals already isolates the display to extraction + audit; the naming stage row and naming runs were filtered out client-side). This culls the server side of the same ruling. THE STANDING RULING, REVERSED BY ITS OWN PREMISE: NamingRunUsage / readNamingRuns / appendNamingUsage were kept at the wave-7 namer kill ONLY because cost-summary's HISTORICAL naming stage read them. With the fold culled, that premise dies — so the whole ledger read path goes too, following the wave-7 embeddings precedent chain: consumer-less ledger seams are deleted, and the on-disk naming-runs.jsonl stays INERT (real trees keep their historical spend bytes; they are simply not displayed anywhere — the same treatment as historical stages.embedding spend). - core/cost-summary.ts: two channels in (chat + notes front doors), two stages out; RecentRun.kind collapses to "extraction" (labels field gone); CostSummaryInput.naming gone; totalDollars = extraction + audit. - ports/store/contracts/config: appendNamingUsage + readNamingRuns ports, store impls + guards, the NamingRunUsage interface, and LEDGER_FILES.namingRuns die. graphRevision no longer counts the file (a smaller revision value is harmless — reads never compare). - components/graph-view.tsx (mechanical, flagged): the defensive .filter(run.kind !== "naming") guards a kind that no longer exists. - Tests: cost-summary's both-channels fold pins; the naming fixtures and the appendNamingUsage store pin die with the seams. |
||
|
|
2cba283009 |
Graph cost data drops the dead cluster-naming channel from the UI
The cluster namer is dead (owner refinement round, 2026-09-24): cluster labels are just session titles / file names now, coined without an LLM, so there is no reason to keep tracking naming spend in the cost UI. components/graph-view.tsx (UI-only; the core fold is untouched): - NEW pure export displayCostTotals(costs) = extraction + audit dollars and unpriced counts. Both the cost pill and the panel headline read THIS — never GraphCostSummary.totalDollars, which still includes the historical naming channel — so the two can never disagree. - GraphCostPanel's stage rows lose the "Cluster naming" line; the headline and unpriced counts come from displayCostTotals. - The recent-runs list filters out kind === "naming" runs entirely (the "N labels" tail died with them). - Mount comment updated: the fold covers chat + notes extraction runs. Note for master/Platform: src/core/cost-summary.ts still folds naming-runs.jsonl into stages.naming / totalDollars (Platform's lane). The UI no longer reads those fields; Platform can cull the seam and the dead ledger whenever convenient — displayCostTotals already isolates the display from whatever the core returns. Pinned in tests/graph-view-filters.test.ts: displayCostTotals with a naming stage of $9.99 + 2 unpriced proves the display sees extraction + audit only ($2.00, 1 unpriced). Live-verified on :3001 — the panel renders Extraction and Audit rows, no naming row, no naming runs in the recent list. 905/905. |
||
|
|
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.
|
||
|
|
4936b776ab |
Scroll-down disc rides the dock: anchored above the composer, never over it
The scroll-to-bottom disc (ConversationScrollButton) was the scroller's last child — DOM order painted it ABOVE the dock overlay, so it floated at the scroller's bottom edge, partly covering the composer (owner refinement round 3, 2026-09-24: it must ALWAYS sit above the composer, never over any element, and stay positioned with respect to the composer — which grows a lot taller when a question owns the band). The fix is a DOM move, not a coordinate: the button now renders INSIDE the DockOverlay, as a sibling of the DOCK container within the dock block — `absolute bottom-full mb-2`, anchored to the BLOCK's top edge. Because the anchor is the block itself (not the scroller's bottom): - it sits above the composer shell AND the stats row, in the gradient's transparent band; - it rides every height change LIVE: typing growth, a pending question expanding the band (verified: the disc moves with the 393px question form, 9px clear), the 180ms fold morph to the slim bar (8px clear of the folded bar — no overlap mid-morph either, height animations reflow per frame); - pointer-events-auto (the overlay is none by default); the isAtBottom/scrollToBottom context still reaches it — DockOverlay is a Conversation child. The use-stick-to-bottom pattern comment updated; the shared ConversationScrollButton component itself is untouched (positioning arrives via className, twMerge swaps bottom-4 → bottom-full). Live-verified on :3001 with a tall transcript, all three states: chat mode (15px clear of the composer textarea), expanded question (9px clear of the form top), folded slim bar (8px clear). 902/902. |
||
|
|
7163544134 |
Ask-dock keyboard round: Esc escapes Other typing, Space deselects, bigger multiselect check
Two owner fixes (refinement round 2, 2026-09-24) in the question dock's ask questions (quiz untouched): 1. ESC ESCAPES THE OTHER TYPING: the ask's "Type your own answer…" row auto-focuses its input on selection, and once in it there was no way out — arrows moved the caret (the typing-surface guard), so keyboard navigation was stranded. Esc in that input now blurs it (focus falls to the page) and pins kbdIdx to the Other row, so: the row STAYS selected (deselection is a deliberate act, never a side effect), the typed text survives, the arrow walk resumes immediately, and the row stays the walked row. stopPropagation keeps the window chain from reading this Esc as abort-turn. 2. SPACE DESELECTS A SELECTED OTHER: the walk's Space on the Other row selected it; now Space on an already-selected Other CLEARS the pick — focus never drops back into the typing field (the core's toggleOther has no single-select off-path; the off-path lives in kbdAct, keeping `selected` intact in multi). Re-selecting — walking back and Space, or a click — is the way back into the field. Clicking the selected row keeps its old meaning (choose Other); only the walk's Space gained the off-path, per spec. 3. MULTISELECT CHECK GLYPH BIGGER: the ✓ inside the multiselect checkboxes drew at the 11px text-xs rung — small next to the 16px box. Now text-base (14px on this app's ladder) + leading-none. The single-select dot and the quiz's rows are unchanged. (Pinned live: 14px computed in the 16px box; the span only mounts on a checked row, so no static pin — noted in the render test.) Live-verified on :3001 (fabricated ask chats, keyboard-only flows): select via Space focuses the input; type; Esc → focus BODY, row still selected, text kept; Space → deselected, focus stays out; re-select → input focused again; listed option displaces in single-select. Multi: Other coexists with checked options through the whole sequence. 901/901. |
||
|
|
9f7dd1a7e9 |
Refinement round: ring popover down, menu order, tok unit, list indent, lowercase levels
Five small fixes (owner refinement round, 2026-09-24):
1. RING POPOVER OPENS DOWN: the ring lives in the chat top bar —
the window's top edge is right above it, so the upward popover was
clipped. top-full mt-1.5 now: it drops below the ring.
2. SESSION MENU ORDER FLIPPED: Rename/Delete first, hairline, then
the extraction coverage block + Extract at the bottom — the info
reads after the actions (was the reverse, the block on top).
3. UNIT TYPED OUT: the sidebar block reads "2k tok uncovered" now —
the tokens unit is explicit (owner: "tok").
4. MARKDOWN LIST INDENT: streamdown ships list-inside with no
container padding — top-level markers sat ON the text edge (only
nested lists got their pl-6 via [li_&]). .lh-prose :where(ul, ol)
{ padding-left: 1.5rem } indents both list kinds as blocks, 24px —
the same measure as the nested-case pl-6. Unlayered CSS beats the
utility layer; :where keeps specificity zero.
5. THINKING LEVELS LOWERCASE EVERYWHERE: the composer pill and setup
dialog rendered THINKING_LABEL (Off/Minimal/…/Max) while every
menu body renders the raw lowercase level — the map is now
lowercase, the level string IS the display form, everywhere.
Live-verified on :3001: list content inset 24px vs paragraphs
(padding-left computed 24px, nested fine); the composer pill reads
"low" with options low/high/max; menu order Rename → Delete… →
hairline → block. 901/901.
|
||
|
|
bc1f668e2a |
Extraction surfaces: sidebar coverage block, chat-bar ring, phase un-pill, panel death
The owner-locked extraction-UX redesign (dispatch 2026-09-24), UI chunk over Platform's data layer (eed4b4e — GET /api/chats/[id] carries `coverage`, the threshold is Graph ▸ extractionChunkTokens): 1. SIDEBAR BLOCK (components/extraction-coverage.tsx + sidebar's session menu): the coverage info block at the TOP of the context menu — uncovered tokens, uncovered messages, running state — then the Extract affordance (optimistic "extracting… / Running" until the server says running; 409 = benign, the trigger raced us), fetched ON MENU OPEN (the block mounts with the menu — on-demand, never eagerly), polled at 2s while a run is in flight. Visually separated from Rename/Delete by a hairline; those stay the two session-management actions. Failed read → nothing (never fake zeros). 2. RING METER (CoverageRing, chat top bar, before the PhaseChip): a 16px donut filling uncoveredTokens / extractionChunkTokens — "how close this session is to its next extraction run". Click → the exact numbers (covered/uncovered tokens, messages, the threshold, lastFoldedAt via timeAgo). Refresh chains: mount, the turn-settle bump (chat.tsx coverageTick), the settings signal (threshold/kill-switch re-read live), a 2s drain-poll while a run is in flight, and the ACTIVITY FEED — a run for this session appearing or settling in the dock's feed re-reads coverage (runs fire from other surfaces — the sidebar menu, the turn pipeline — and no turn-settle bump announces them; the runs-empty-transition the graph view rides). Vanishes entirely when the graph is off or before the first honest read. Running = the whole ring PULSES (implementer's visual call: motion is the working-state signal in this app's quiet register). 3. PHASECHIP UN-PILL: the phase label is a flat tag — no border, no rounding, no padding. The Plan chip is the only button-shaped thing in the top bar. Drag-surface (pointer-events-none) and aria semantics unchanged; the transcript PhaseDivider keeps its look. 4. PANEL DEATH: components/graph-coverage.tsx, its graph-view mount, and tests/render-graph-coverage.test.tsx die whole. Confirmed: after this, NOTHING client-side consumes GET /api/graph/coverage — the only remaining client use of the coverage endpoints is the Extract POST (kept, by design); the routes stay (Platform's call). Pure seams exported for tests: ringFill (clamp math), CoverageMenuBody, CoverageRingGlyph, CoverageRingDetail. 901/901. Live-verified on :3001: ring fill arcs match the API byte-for-byte (483/4096 = 4.45/37.70; 1991/4096 = 18.32/37.70); the sidebar block reads "2k uncovered / 4 messages unextracted"; a real Extract run went optimistic → dock run → menu settled "fully extracted" AND the ring drained to the empty arc (the feed chain); graph off → the ring vanishes; the flat phase label renders chrome-free beside the Plan pill. |
||
|
|
81709afd22 |
Stats-bar catalog join + dock numerator consistency (context-meter close-out)
Platform's context-meter pair (04e336d, 18cc493) left two client seams in my band; this closes the arc: 1. STATS-BAR CATALOG JOIN: contextWindow's denominator resolves ONLY from pi's live catalog — stats-bar now builds the provider:id -> contextWindow index client-side (catalogLimits in model-picker.tsx, the client twin of the server's catalogContextLimits join) over the picker's existing /api/models fetch/cache (useModelCatalog exported; module-level dedupe, one fetch feeds both ModelControl and the meter). Catalog pending or failed -> undefined index -> limit null -> USAGE-ONLY (Platform's interim render IS the null path; the honest-render precedent, never a fake window). 2. DOCK NUMERATOR CONSISTENCY: toBlobs' context fold was still last-step input+output only — now the same cache-inclusive footprint the session meter uses (all four directions; provider "input" EXCLUDES cacheRead/cacheWrite, so the old fold read wildly low on cache-heavy jobs). Same metric everywhere. Review of Platform's interim edits in this band: verified as-shipped — stats-bar's usage-only render (summary + popover), FeedJob.contextLimit with the line-hides null treatment, and the render-test rewrites all fit. Only fix needed: none beyond the above. Tests: catalogLimits pinned pure (index shape, provider:id keying, missing-model absence); toBlobs pinned on the cache-inclusive numerator (10k+0.5k+120k+30k = 160.5k against a 200k feed limit). 889/889. Live-verified on :3001: seedplan's bar reads "18.7k / 1M" (footprint 18725, catalog 1048576); a nope/unknown-model chat renders "12.8k" usage-only with cost "-"; the popover detail matches; the dock blob's context line renders "946 / 1M" from a server-joined feed (an unintentionally real runner-adopted job — the full chain, live). |
||
|
|
4ded547cb5 |
Context-meter amendment: CONTEXT_LIMITS dies — pi's live catalog is the only limit source
Owner ruling (2026-09-23, amendment to 04e336d): no hardcoded fallback exists anywhere. contextWindow(record, limits) resolves the denominator ONLY from the live index (provider:id -> contextWindow straight from pi's catalog); a model missing from it (historical session, package update, failed fetch) reads limit null and the meter renders usage-only — the honest-render precedent, never a fake window. - src/core/prices.ts: the 1354-entry CONTEXT_LIMITS table (65KB) and its generation apparatus deleted; the fold returns limit: number | null. PRICES untouched (regen verified byte-identical via the reworked sync-pricing script). - scripts/sync-pricing.ts: generates PRICES only; contextWindow leaves the priced map, the guard, and the template. - The dock meter's owner-selected treatment: the activity feed carries the limit server-side. catalogContextLimits() (server/model-catalog.ts) builds the index over the same keyed providers the picker federates; activityFeed() joins it onto every running job (ActivityJob.contextLimit, null when the model is missing — the dock reads it off the feed, no client-side table, no regression while the UI's catalog-join chunk lands). - components/activity-dock.tsx: toBlobs reads job.contextLimit (import of CONTEXT_LIMITS gone); stats-bar renders usage-only on null limit (mechanical interim — UI stream's join chunk follows). - tests: usage.test.ts pins the no-fallback contract (index hit / absent / empty / missing-model -> null); activity-feed.test.ts pins the server join (catalog model -> 1048576, unknown -> null, no-usage -> null); render pins updated to the interim usage-only render. |
||
|
|
f7a04f6c02 |
Default bold ink #c96b4a -> #ee928c
Owner default change (2026-09-23): the factory strong-color token moves to the softer coral. The factory lives in the stylesheet token (both theme blocks in globals.css) - settings' boldColor:null means "factory" by design, so no record/schema change. The settings row hint + placeholder follow, plus the placeholder test and two comments that cited the old literal. Live-verified: unset boldColor renders rgb(238,146,140) (#ee928c) on the real <strong>; dialog hint reads "Empty = #ee928c". |
||
|
|
fb72b4fee3 |
Bold renders the real element: the Bold color setting applies
The owner's repro: the appearance Bold color never applied anywhere. Root cause: streamdown's default `strong` component renders bold as <span class="font-semibold" data-streamdown="strong"> - NOT a <strong> element - while the app's ink plumbing (.lh-prose :where(strong, b) in globals.css, zero specificity by design) matches strong/b ELEMENTS. So bold text never took --lh-ink-strong: not the factory #c96b4a, not any settings boldColor (it rendered plain foreground at weight 600). Italic always worked because streamdown leaves `em` native - hence the asymmetry the owner noticed. Fix: a `strong` override in mdComponents (the stable module-level set, so Streamdown's memoized tree keeps identity) renders the semantic element: <strong data-streamdown="strong">. Screen readers announce emphasis again, the designed token rule applies unmodified, and the weight reads 700 (the .lh-prose rule) instead of the span's 600. Both Streamdown surfaces (MessageResponse, reasoning) pass mdComponents, so every markdown surface is covered. Verified live: bold in seedplan now renders #c96b4a at weight 700 in a real <strong>; after PUTting boldColor #7dd3fc the live strong flips to rgb(125,211,252) with no reload; italic unchanged (#6fa9d6 native em). Scratch record restored to unset. |
||
|
|
e826088cc8 |
Rename-shuffle fix: label-only channel + three hygiene items
The live-rename interlude routed label changes through a fresh snapshot object, which flowed setData -> geometry rebuild - and the rebuild treats the last-fetched position ledger as authoritative and cancels any pending drag commit, so a rename visibly rewound the whole graph whenever display had drifted (recent drag, mid-anneal, physics re-anneal). - Renderer gains setClusterLabels (a setPhysicsTuning sibling): pure applyClusterLabels patches the data-side cluster records and the built region meta (re-wrapping the changed entries with the same measure the build uses), markDirty only. Never touches nodes, the sim, positions, or the pending commit. The hull-silence guard travels inside it: a null-label cluster is a gone record - no rename can reach it. - The lh:chat-renamed listener stops building snapshot objects: renameSessionsPatch writes the sessions entry in place (pure display state - the detail card's provenance line), the cluster label goes through the new channel, and a tick bump re-renders the view. Identity preserved everywhere -> the [snapshot]-keyed setData feed cannot fire. - Hygiene (a): a fresh sim starts at alpha 1 and never decays while stopped - the first post-rebuild drag re-annealed the whole graph at full strength. buildSim now initializes alpha at the settle floor, so first-drag matches steady-state drag. setPhysicsTuning still rehearses alpha(1) when a full re-anneal is the point. - Hygiene (b): nodes missing from the position ledger used to dump at (0,0) via the ?? 0 fallback. seedMissingNodePositions seeds them on a deterministic ring around the known positions' centroid (FNV-1a hashed angle, golden-angle spiral offset). - Hygiene (c) is store-band (writePositionMap's writeFileSync -> tmp+rename): flagged for Platform, not crossed. Tests: applyClusterLabels suite (patch + re-wrap, hull silence, no-op, explicit null, unknown ids), renameSessionsPatch identity pins (sessions map and clusters array identity preserved - the contract that keeps setData from firing), seedMissingNodePositions determinism + ring math. Live-verified: mid-anneal rename shows a 4-world-unit max disc jump (no teleport), the commit PUT survives the rename, rest renames leave the disc build byte-identical, and a held first drag displaces discs ~14 units instead of re-forming the graph. |
||
|
|
976be50a81 |
Polish round: node-delete chrome + rename alignment
Owner polish dispatch (2026-09-23): - Right-click no longer pins a node or opens the detail card: the canvas pointerdown/up handlers now gate on the primary button, so the context menu arrives alone. - Node context menu: label is just "Delete" and the shell hugs its content (w-44 -> w-fit); the menu body keeps focus for arrow cycling but never shows the browser's default focus ring (outline-none on the MenuShell body - applies to every menu). - Delete confirm: "Delete node?" title in CMU Serif, red (text-destructive); the node name sits below in JetBrains Mono inline-code; the Delete button carries the destructive treatment (border + tint, fill deepens on hover). Rest of the dialog stays neutral. - Sidebar rename row: text and metadata stay at the idle row's x - the status spacer and the input's border/padding are gone; the underline is the only editing cue. |
||
|
|
92943864c4 |
Node deletion affordance (node interlude): right-click menu + full-death confirm
Platform's 50bfcba engine, surfaced. Three pieces: 1. THE RIGHT-CLICK (graph-canvas.ts): a contextmenu listener — always preventDefault (the native menu on a full-bleed custom canvas is never useful) — picks the node under the pointer and hands the canvas-relative position to a new onContext callback (null on empty stage). pickAt already respected the visibility filters: a hidden node cannot be right-clicked, same as it cannot be clicked. The view opens a MenuShell anchored at a zero-size point-div positioned at the pointer — MenuShell's placement machine measures it like any element, so the menu gets the full discipline: placement, focus on open, arrow cycling, honest-fit flip. Delete… is its one action; the dismissal contract is the sidebar's (wave 6): outside-click / Esc (claimed) close, and a camera move closes it too — a menu floating over a moved graph is anchored to nothing. 2. THE CONFIRM (delete-node-dialog.tsx, portaled z-50 like the session-delete dialog whose tone it follows): the pre-flight impact read (GET /api/graph/nodes/[id]) renders the plain facts — link count, quiz-answer count, session-note count (zeros stay visible — hiding a zero fact would understate the blast), and the referencing sessions with display names joined CLIENT-SIDE from the snapshot (chat titles; imported-notes file paths; unknown ids honestly themselves). "This cannot be undone" says the permanence. The 409 extraction_running renders the dock phrasing (EXTRACTION_RETRY_LINE — the server names the extraction, the ACTIVITY DOCK is the surface the user actually watches drain) with a Try-again; every other failure shows the server's line verbatim. The honest 404 (deleted elsewhere meanwhile) states it plainly and its Close rides the same refresh. The pure body is exported separately (the settings-dialog pattern) — SSR pins everything the live dialog shows. 3. SUCCESS: onDeleted dispatches lh:graph-revision with the returned graphRevision — the wave-6 chain refetches snapshot + costs. The detail card closes if it pointed at the dying node (the refetch would close it anyway; the same frame should not show a card for a node the user just killed). Clusters shrink / unrender below the minimum on the server's read — the client just renders the refreshed snapshot. Tests: 867/867 (+8 — the join helper, honest plurals, the 409 phrasing pin, the dialog body at loading/gone/impact/failure states). Live-verified on :3001 against the real engine, scratch data: impact facts match the API, the intercepted 409 shows the dock line and Try-again recovers into a real delete, the three-member cluster died below MIN_CLUSTER_SIZE server-side, the labeled seedplan cluster + its canvas label died with it (cold load draws nothing stale; the in-session refresh refetches and the dying node's label stops drawing), other nodes survived, and the full menu contract holds (empty stage → no menu; Esc, outside-click, camera move all close). |
||
|
|
7479dce8ef |
Wave 7 interlude: prober tool-call chrome + live rename cluster labels
Graph tool-call visibility — the prober's list_sessions /
list_propositions rendered like nothing happened in the transcript
(the registry default is silence for unregistered tools). They now join
the EXACT read/ls/find/grep discipline: artifactRenderers maps both to
the ToolCallCard — collapsed one-liner, expandable Input/Output card.
The results ride the card's string path verbatim (the tools' own
compact name/level/date line format), exactly how read results render.
- list_propositions gets a headline arg: the session id is the call's
subject — the collapsed line reads "list_propositions chat_…".
Shown as given, never resolved (the same no-resolution rule as
read's path). list_sessions carries no arg (empty input — the
ls-with-no-path discipline).
- No bespoke row-layout card: the outputs are pre-formatted strings
(the model-facing format IS the learner-facing format here), and
the dispatch's "SAME look" directive settles the shape question.
Live rename labels — renaming a session anywhere (sidebar inline row,
chat header) already announces lh:chat-renamed {id, title}; the graph
view now listens. The birth-session partition means the title IS the
cluster label, so the patch is targeted and in-place, no refetch: the
snapshot's sessions map (the detail card's provenance line) and the
one matching cluster. The new snapshot object flows the existing
setData → geoVersion → rebuild chain, so the wrapped label (wave 7's
wrapRegionLabel) re-wraps for the new text. Unlabeled clusters keep
their hull-only silence — a null label is a GONE record, the guard
holds the line.
Tests: registry pins for both tools + the prober-card render (collapsed
headline, verbatim rows, no-arg discipline). 855/855; live-verified on
:3001 with fillText instrumentation: both cards render collapsed and
expandable in the transcript, and a sidebar rename flipped the canvas
cluster label old→new with the old label's draws stopping cold.
|
||
|
|
7a17230f26 |
Wave 7 close-out: the graphTools surface, wrapped cluster labels, the orphan sweep
Part 1 — the surface:
- Settings ▸ Agent ▸ Tools gains the prober GRAPH-TOOLS row as its FIRST
row (owner ruling: in the Tools section, not the tool-toggles list —
its subject is the map, its mechanism is agent capability). Writes
agent.graphTools {enabled} (absent = on, the toggle discipline) via
the section PUT; composeGraphTools pure seam. Its own key in the agent
contract — never in the tools map.
- The row carries the search-row hint discipline: "Connects prior
sessions' knowledge into new ones — the prober reads the knowledge
map" (owner wording). The honest interplay composes both: when
graph.enabled is off the row dims + disables with a "Graph is
disabled" hint (the effective gate itself is server-side, per-turn
fresh). AgentSection reads the graph section (record.graph, passed by
the dialog) — read-only.
- Cluster labels wrap: wave 7 made labels session names and FILE PATHS,
which the old single-line fillText could not bound. wrapRegionLabel
(pure, exported): word-wrap first, char-split long paths (the
node-label discipline), capped at 3 lines, overflow folded into an
ellipsized last line (owner: wrap, cap 2–3, ellipsize). Pre-wrapped
once per geometry build at the SCREEN-fixed size (viewer-relative
labels — the wrap never goes stale mid-camera-move), drawn centered
line-by-line at the centroid. Deleted-session clusters keep their
hull-only honest silence (label null → no lines).
- The orphan sweep: StatsBar's leftSlot prop is dead (its one consumer,
the knowledge-context slider, died in c690181) — removed, and pre-chat
views now render NOTHING on the left (owner ruling this round: a
"— · —" summary over an empty popover is pure noise) — the dictation
middle just widens. Stale knowledge-context comments swept in
chat.tsx (adoption-window + activity-nudge docs), activity-dock.tsx
(useActivity ground rules), dictation.tsx (×2 adaptive-meter docs),
home.tsx (pre-chat dock comment).
Part 2 — Teacher's c690181 review: clean. Imports, KIND_ICON/KIND_LABEL
entries, render branches and event seams all excised whole; the probe-
handoff branch re-indented intact and merge-adopted; tests updated in
lockstep (merge-adopted, record-adapter, render-new-session retired).
Platform's 4b60e0f roster-string fixes (labeler dropped from both
SLOT_META blocks) reviewed — consistent across agent-section +
setup-dialog.
Gates: tsc clean, 854/854 (8 new: wrapRegionLabel cases, graphTools
SSR rows ×3; stats-bar + ladder fixtures updated to the new rulings),
lint 0, build ok. Live on :3001: graphTools row first-in-group with
hint, off→{enabled:false}→on round-trip, graph-off dimming + hint +
disabled toggle, pre-chat rows summary-free, chat view keeps its
summary, long-title cluster label wrapped without console errors
(visual read unavailable this session — pure wrap is unit-pinned).
|
||
|
|
d1b46132b9 |
Wave 7 close-out: the embedding ledger seam dies; notes birth via mapping lines
Part 1 — the ledger + remnants interlude (master-dispatched, owner ruling 2026-09-23: nothing configures the graph beyond chat models): - The embedding ledger surface is gone: readEmbeddings/writeEmbeddings ports, the EmbeddingsLedger contract, EMBEDDINGS_FILE (config, store impls, graphRevision source list), and graph-deletion's vector-pruning step. On-disk data files stay untouched (inert history). - The extraction embedder seam dies wholesale: ExtractionPipelineDeps drops the embedder dep (chat-handler + backfill/import-notes stubs go with it), the 'embedding' phase leaves ExtractionPhase, the usage capture/persist helpers die, and stages.embedding leaves ExtractionRunUsage (historical records keep their shape — reads are lenient). Extraction is two stages again, like the local-embedder era. - Consumer-less remnants: latestQuizOutcomes + QUIZ_EVENT_KINDS, clusterContentId, matchClusterLabelings + Community + ClusterLabeling, the readClusterLabels/appendClusterLabel port seams + store impls + LEDGER_FILES.clusterLabels. NamingRunUsage/readNamingRuns STAY (standing ruling — the cost pill renders historical naming spend); clusterHue + MIN_CLUSTER_SIZE stay (live). - The 'labeler' role dies: BACKGROUND_MODEL_ROLES, ROLE_SLOT, ModelRole, the catalog pin projections, and the UI roster strings. RolePins keys shrink with the union. The notes birth fallback (owner-approved 2026-09-23, surfaced by the interlude's live smoke: production notes imports create provenance-EMPTY nodes (importNotes passes messages: null — the file's anchor is the mapping line), so the provenance-only birth rule left every imported file unclustered and invisible to the prober's session list, against the locked file-birth ruling. A provenance-empty node now births under its FIRST mapping line's chatId (ledger order); only notes-born nodes ever hit the fallback (chat nodes always carry provenance; a re-encountered node's link-append re-opens the provenance rule). Deletion-safe: mapping lines of deleted chats are stripped alongside their provenance. Live-verified on port 3004: a fresh notes import clusters under its src_ id with the file path as the label, end to end with no embedder. 845/845 tests, typecheck/lint/build green. EOF ) |
||
|
|
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. |
||
|
|
c86430e8bf |
Wave 7 chunk B: remove the embeddings machinery — the ladder collapses
The owner's wave-7 ruling: embeddings die entirely (git history is the
stash); clustering is birth-session derived (chunk A, 950ddfd), labels
are session names, and the prober's graph tools replace injection.
- settings: GraphSettings collapses to { enabled } — embedding,
embeddingModel, clustering, labeling all die. Migration story: stale
record keys are UNREAD and dropped by the sanitizer's reconstruction;
the next graph-section PUT (replace-whole) persists the collapsed
shape. The settings-panel GraphSection renders one master row.
- deleted: src/server/embeddings.ts (adapters, resolveEmbeddingPortFor,
embedNewNodes, embeddingPort, embeddingModelCatalog), the
GET /api/models/embeddings route, scripts/embed-graph.ts, tests for
all three, and the /api/models/embeddings picker surfaces in the UI.
- prices: EMBEDDING_PRICES overlay + callCost fallback die. cost-summary:
the embedding stage fold dies — back to three stages (extraction/
audit/naming) per the owner ruling; historical stages.embedding spend
in old run records is simply not displayed.
- ports: EmbeddingPort/EmbeddingResult/EmbeddingTask die. The embeddings
LEDGER surface (readEmbeddings/writeEmbeddings + EmbeddingsLedger +
EMBEDDINGS_FILE + graph-deletion's vector pruning) STAYS — inert data,
kept only so graph-context.ts compiles until Teacher's pass removes
it; the seam dies in the follow-up interlude.
- chat-handler (approved cross-lane unwiring, wave-6 precedent): the
extraction pipeline's embedder becomes a documented no-op (nothing
embedded, nothing spent, ledger untouched) until Teacher's pass
removes the phase; the graph-context injection dispatch gate, the
graphContext deps wiring, the setContextRespawn seam, and
turn-service's triggerGraphContext are gone — injection is dead
code pending Teacher's file deletions.
- scripts: backfill-graph/import-notes pass the same no-op embedder;
replay-layout derives clusters from birthSessionPartition (the same
partition the real settle uses) instead of the embeddings ledger.
- settings-body: parseGraphSettingsBody reads the master switch only —
old-shape keys in a body are ignored, not rejected; bad shapes 400.
- next.config: the @huggingface tracing-exclusion guards die with the
last reference.
Cross-lane flags: extraction.ts/graph-context.ts keep stale embed
comments + the embed phase until Teacher's full pass (their parallel
interlude owns those files — zero overlap kept by design);
semantic-clusters.ts + its test stay one more round (extraction.test.ts,
Teacher's file, still imports it — they delete it with the namer cases);
ChatSettings.graphContextTokens + the graph-context-control UI stay
until Teacher+UI.
Gates: tsc clean, eslint clean, 885/885 tests, build compiles.
Live-verified on the port-3004 dev tree: PUT /api/settings/graph with
dead keys collapses on write, bad shapes 400, GET reads {enabled: true},
/api/models/embeddings 404s.
|
||
|
|
fb66606555 |
Wave 6 chunk B: the embeddings surfaces — picker, re-embed gate, gray-outs, cost pill
- Settings ▸ Graph grows the embedding-model picker (graph-row-embedding-model):
GET /api/models/embeddings' live-federated BYOK catalog (re-fetched on mount
and every settings save), rows priced from EMBEDDING_PRICES ("$x.xx/1M in",
honestly "—" when unlisted), ids shown AND stored as full colon-safe slugs
(openrouter:liquid/…:free is never split), plus an Unset row writing null.
Empty catalog = nothing keyed: the trigger BECOMES the deep link to Keys
("Add a provider key first…") — and deep-links fired while the dialog is
open now actually switch sections (initialSection was read once at mount;
the body follows it now — pre-existing gap, first exercised by this link).
- Re-embed gate (owner design): picking a model that differs from the stored
one holds the write behind a confirm strip carrying the honest node count
from GET /api/graph; zero propositions skip the strip (nothing to re-embed)
and land directly. Unset never confirms — the port just stops resolving.
The copy says the re-embed runs with the next background pass (the staleness
machinery — a settings PUT alone never triggers it; no server endpoints
were added).
- Gray-out, the searchStatus precedent: with no model configured, the ladder
rungs Embedding/Clustering/Labeling dim + disable with a "Needs an
embedding model" hint (master switch stays lit), and the stats-bar
GraphContextControl swaps its slider for a dimmed deep-link button to the
Graph section. Loading never grays; every settings save re-reads (signal).
- Cost chrome: the pipeline popover grows the Embeddings stage row
(costs.stages.embedding, wave 6's folded stage — "— · 0 in / 0 out" until
runs exist, same as its siblings).
- Master's ride-along: the core-barrel ChatDeleteMode comment now says two
type-only things (it always exported both).
Gates: tsc clean, 912/912 (8 new: price lines, model-dependency predicate,
unset/set SSR dimming; 2 ladder fixtures updated to the new no-model
semantics), lint 0, build ok. Live-verified on :3001: unset deep-link →
graph section, menu pricing + colon-safety (37 options), confirm strip with
"20 propositions", Cancel keeps the record, Unset writes immediately,
in-dialog empty-catalog deep-link lands Keys, Embeddings row in the popover.
|
||
|
|
1c27694dbc |
Wave 6 chunk A: sidebar context menu (Rename/Delete) + the two-option delete dialog
- Right-click any session row: MenuShell context menu, exactly two actions (owner ruling): Rename and Delete… — MenuShell discipline (placement, focus, arrow cycling, lh:menu-open announcements), agent-section's outside-click/Esc contract with [data-lh-menu] carve-outs. - Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur commits through the existing plumbing — PUT /api/chats/:id then lh:chat-renamed. The open chat header now LISTENS for the event too, so renaming the active session from the sidebar updates the header in place (it only dispatched before). - Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure seam for render pins): "Delete the session only" (preserve-graph — the graph keeps every contribution) vs "Also remove its graph traces" (delete-nodes — shared topics survive, only-this-session topics die). 409 turn_in_flight/jobs_running and 503 extraction_winding_down render the server's line verbatim with a same-mode Try-again. - Success: the sidebar patches its snapshot instantly, the active session navigates home (chat unmounts — drafts die with it), and lh:graph-revision announces the returned revision — the graph view refetches the snapshot AND costs (deletion is the one writer the status poll can't see). - ChatDeleteMode type re-exported through the core barrel (the shell's one needed seam from core/graph-deletion.ts). Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001: context menu open/Esc, inline rename (row + API + active header), both delete modes on scratch sessions, simulated 409 + retry-then-succeed, active-session navigation home, graph+costs refetch deltas on home. |
||
|
|
0a4a9a11de |
BYOK provider embeddings (wave 6 chunk B): kill local transformers
Replace the @huggingface/transformers local embedder (~316MB ONNX cache) with an EmbeddingPort seam: graph.embeddingModel (nullable "provider:id" slug) selects an OpenRouter/OpenAI/Gemini adapter, resolved fresh per call from settings + the auth store so key rotations land immediately. - src/server/embeddings.ts rewritten: slug grammar, injected-fetch adapters (OpenRouter input_type, Gemini taskType batching, OpenAI index-ordered rows), honest resolution (null = skip-with-info, set model with missing key = honest error), embedNewNodes with model-change re-embed wipe + ghost pruning + usage steps, federated catalog (OpenRouter /api/v1/embeddings/models — VERIFIED live, the inverse path 404s; OpenAI text-embedding- filter; Gemini embedContent filter). - GET /api/models/embeddings: federated catalog, empty when no keys. - Extraction tail records stages.embedding usage into the run's money channel; EMBEDDING_PRICES overlay in core/prices.ts (outside the sync-generated block), callCost falls back to it; cost-summary folds the fourth stage; graph-context injection resolves its embedder per run, fails honestly when unconfigured, records query tokens. - Settings: GraphSettings.embeddingModel (null factory), section parser validates slug shape (id may contain colons — OpenRouter :free suffixes, live catalog fact). - Removal: @huggingface/transformers uninstalled, PATHS.modelCache and onnx tracing entries deleted, @huggingface tracing-excludes guard added, stale sharp/libvips comments updated to historical. Live-verified on port 3004: catalog federates 36 rows (33 OpenRouter + 3 OpenAI), embedNewNodes embedded 10 nodes via the free liquid/lfm-2.5 model (dim 1024, 435 prompt tokens, 1 call, $0), settings PUT rejects bad slugs with 400. Gates: typecheck, lint, 895/895 tests, build. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |