From c33de1175b0c608498e9a4f4422cd29f42c7ccc4 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:28:37 -0700 Subject: [PATCH] Render deterministic rule hits instantly, not on the LLM's clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule pack in prose.ts already found "a apple" — articles, pluralAfterNumber, subjectVerbAgreement, uncountables are all there, and they already surface as real mechanics cards. But mechanicsFindings only ran inside runCheck, behind the same 4s checkpoint debounce as the model, and only reached the screen via the server's reply. A free, instant, offline-capable detection was being delivered on an LLM-shaped delay. The rule pack now runs on its own 250ms fuse and renders its findings with no network at all, as provisional cards. The mechanics submit follows; its reply is authoritative and clears them. If the reply never comes — offline, server down — the cards simply stay, which is the whole point of having rules that need no model. Provisional cards are keyed by wording rather than position, so one can't flicker into a duplicate of its own persisted twin while she types around it. resolveServerId maps a card to the row the API can act on, awaiting the in-flight submit, so accepting inside that window still records the keep and plants its word in the garden instead of being quietly dropped; null means there is no row and the edit has landed regardless. Findings she actions while provisional are remembered client-side, because the detector has no memory between runs. runCheck no longer re-submits what the fast pass already filed — it's the catch-up path for when that submit failed. The arrival chime keys rule-pack cards by wording too, so a finding doesn't chime once as provisional and again as persisted. Not done, deliberately: no distinct style for unconfirmed local hits. The rail renders both engines identically on purpose, and a provisional card now lives for one LAN round-trip. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua --- UX_REVIEW_2026-07-27.md | 49 +++++++++++++++++++++++++++++++++ web/src/App.tsx | 23 +++++++++++----- web/src/hooks/useCheckpoint.ts | Bin 8478 -> 14498 bytes 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/UX_REVIEW_2026-07-27.md b/UX_REVIEW_2026-07-27.md index 951dc57..238d9d3 100644 --- a/UX_REVIEW_2026-07-27.md +++ b/UX_REVIEW_2026-07-27.md @@ -166,6 +166,52 @@ in the status bar. Two independent levers, both worth doing: offline; during a full check, at least one card appears before the last chunk finishes; the status bar shows a running count. +### 3b — DONE (third session). The rules existed; the latency didn't. + +Scoped against the code as the handoff advised, and the handoff was right: +`prose.ts` already carries every rule this item asks for — `articles` +(a/an), `pluralAfterNumber`, `subjectVerbAgreement`, `uncountables` — and +they already surface as real cards via the `mechanics` family. Nothing to +write there. The gap was purely *when* they render: `mechanicsFindings` ran +only inside `runCheck`, behind the same 4000 ms checkpoint debounce as the +LLM, and only reached the screen via the server's reply. So a free, +instant, offline-capable detection was being delivered at network speed on +an LLM-shaped delay. + +**Implemented:** +- `useCheckpoint.ts` — the rule pack gets its own `FAST_MS = 250` fuse, + separate from the 4 s checkpoint. It renders its findings as + *provisional* suggestions with no network at all, then persists them; the + server's reply is authoritative and clears the provisional set. If the + reply never comes (offline, server down) the cards simply stay — which is + the point of a rule pack. +- Provisional cards carry a `local: ` id. The merge + matches on wording, not position, so a card can't flicker into a + duplicate of its own persisted twin while she types around it. +- `resolveServerId` maps a card to the row the API can act on, awaiting the + in-flight submit if she accepts inside that window — so an early accept + still records the keep and plants its word in the garden instead of being + silently dropped. Null means no row exists (offline); the edit has landed + regardless. +- Findings she actions while provisional are remembered client-side + (`actionedRef`), because the detector has no memory between runs. The + server already keeps the equivalent record for persisted rows. +- `runCheck` no longer re-submits mechanics for text the fast pass already + filed; it's now a catch-up path for when that submit failed. +- `App.tsx` — accept/dismiss go through `resolveServerId`; the arrival + chime keys rule-pack cards by wording so one finding doesn't chime twice + (once provisional, once persisted). + +**Deliberately not done:** no distinct "modest style" for unconfirmed local +hits. The rail renders LLM and rule-pack cards identically on purpose (see +the note on `Suggestion.source` in `client.ts`), and a provisional card now +lives for one LAN round-trip. Styling it differently would be a visible +regression against an existing decision, not polish. + +**Still open from item 3:** the incremental-surfacing half (per-chunk LLM +results) and the running count in the status bar — both belong with item 2's +chunking and item 8's status-bar summary. + ## 4. Rail scrolls away from the text With ~7 cards the rail is taller than the viewport; scrolling to reach @@ -289,6 +335,9 @@ if you're comparing against memory of the live site, that's why. reproducible) and item 5's original premise (re-scoped, much cheaper now). **Untouched:** items 2, 3, 4, 6, 7, 8. +*(Third session: item 3b done — see the subsection under item 3. Item 3's +incremental-surfacing half remains. Untouched: 2, 4, 6, 7, 8.)* + **Suggested next:** item 3b, the instant local rules layer — but it is **largely already built, in `main`**, and the item as written doesn't know that. Before writing any rules engine, read: diff --git a/web/src/App.tsx b/web/src/App.tsx index ff2b504..95606cf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client' import { useAutoSave } from './hooks/useAutoSave' -import { useCheckpoint } from './hooks/useCheckpoint' +import { findingKey, useCheckpoint } from './hooks/useCheckpoint' import { useSpellChecker } from './hooks/useSpellChecker' import { useTags } from './hooks/useTags' import { DocList } from './components/DocList/DocList' @@ -90,6 +90,7 @@ export default function App() { runVoice, runCollocation, removeSuggestion, + resolveServerId, } = useCheckpoint(currentDoc?.id ?? null) // Browser-side spell checker — loads the en-US dictionary once per session. const { checker: spellChecker, addWord } = useSpellChecker() @@ -350,17 +351,20 @@ export default function App() { // Accept applies the replacement in the editor (handled in EditorCore) and // marks the suggestion accepted; dismiss just rejects it. Both drop it locally. + // A rule-pack card can be accepted before its row exists — the edit has already + // landed either way, so a missing id just means there's nothing to file. const handleAccept = useCallback( async (s: Suggestion) => { removeSuggestion(s.id) setAcceptTick((n) => n + 1) try { - await api.acceptSuggestion(s.id) + const id = await resolveServerId(s) + if (id) await api.acceptSuggestion(id) } catch (err) { console.error('accept failed', err) } }, - [removeSuggestion], + [removeSuggestion, resolveServerId], ) // After restoring a version, swap the restored doc into the editor. Bumping @@ -398,12 +402,13 @@ export default function App() { async (s: Suggestion) => { removeSuggestion(s.id) try { - await api.dismissSuggestion(s.id) + const id = await resolveServerId(s) + if (id) await api.dismissSuggestion(id) } catch (err) { console.error('dismiss failed', err) } }, - [removeSuggestion], + [removeSuggestion, resolveServerId], ) // Play a soft sound when freshly-checked suggestions arrive — one per distinct @@ -411,10 +416,14 @@ export default function App() { // a pile-up. We track which ids we've already chimed for, and only chime for // recently-created suggestions so opening a doc with old pending advice stays // silent (the existing set was created in a past session). + // Rule-pack findings are chimed by their wording, not their id: the same fix + // appears first as a provisional card and then as its persisted row, and the + // writer should hear it once. const chimedRef = useRef>(new Set()) useEffect(() => { - const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id)) - fresh.forEach((s) => chimedRef.current.add(s.id)) + const key = (s: Suggestion) => (s.source === 'local' ? `local:${findingKey(s)}` : s.id) + const fresh = suggestions.filter((s) => !chimedRef.current.has(key(s))) + fresh.forEach((s) => chimedRef.current.add(key(s))) const justMade = fresh.filter( (s) => Date.now() - new Date(s.created_at).getTime() < 12_000, ) diff --git a/web/src/hooks/useCheckpoint.ts b/web/src/hooks/useCheckpoint.ts index a0a8f1f501edd088290cc2539b22facad17aa61b..b4b578acd1688aa67bdab41c0bde0e5b3b7dc042 100644 GIT binary patch literal 14498 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 delta 824 zcmY*Xzi$&U6joXlln5d*6i7?pAyrBuE=iGK0cB`YA%>O#BnGMu?%hjb;j@G7YY0^c zVr5BXW(FqqgBc02vmq81CJ=uE67SqJftzgWd*82jZ=bx`oU9$y*N^PWbaLu zw3L20S6oPMubj>8-hv1%h89A>QQErU5dxDD^idmbEHDGMhXwFovk8?L!yc17G!^Ku zn2RCoV@GMsV9?bpj+ur&GsfLrE$__-HJZMXhS-Hh77Za}T66UDaRWBd*gAHIi8M)H zNn%#gvvSETX9BDq!jBiPXPja|?;gtaUMh4wn&^UDJRKm>HhUk#Q~( zvK%LrP%7(Qm9Ds4dD(reogZIqwLm69)ZlPB0bY521E3=Aql{2{e=`NreM*UV9Am_p zMT$UvGt96;a$FkAq=l+fu)V!g3&5zEgeN8Bm}2k_rw|6MAEksg(GpBgB_fg)3j;=J zr{x%-2LJWm_=hs2#tF`CZ->?fEnt3>^?BpCv+UV#v&y5uS5@@aJJSNIRLSz`TFrAh z25av5h0-cHdysUyv@%Wt)f4^<<%-SA?p^7|Quf1)Qc-R%x<5Db>1X(sKQjtoS0}RF z!|<{De(jh088G+tVln+vzqjBv)*rd?^=IP@wO1<;*);een7LX2VKz?N4d6iW&>uo2 rgbJDU7a`&;HIURUA)`cK03`a5u*awN30M(EW2_Evo(4vXg;C)z1qT&0