Correctness: - useCheckpoint: clear the busy flag unconditionally so overlapping explicit passes don't strand each other's spinner; explicit actions now also supersede a queued auto-check and clear the stranded "checking" dot. Deduped runVoice/runCollocation into runExplicitPass. - EditorCore: token-guard the auto-capture so a late capture can't resurrect a removed word; move toggleSaveWord side effects out of the setWordInfo updater (StrictMode double-fire); fix sentenceAround offset desync via shared exampleAt (textBetween + parentOffset, single resolve); optimistic saved state so the heart doesn't flash unsaved. - vocab capture: normalize word to lower+trim (matches lexicon) so "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList. - GardenPanel: Promise.allSettled so a /due failure doesn't blank the whole garden; scrim click during review ends the review (mirrors Esc); gate footer on !error; O(1) due lookup via a Set. Features requested in review: - Definition-only review card: add vocab_words.definition (migration 0007) as an English fallback meaning, threaded through capture and used by review/garden when there's no Chinese gloss. - Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't push a word out of rotation for years. Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback, TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
167 lines
6.6 KiB
TypeScript
167 lines
6.6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { api, type Suggestion } from '../api/client'
|
|
|
|
const DEBOUNCE_MS = 4000
|
|
|
|
// useCheckpoint manages the grammar-checkpoint lifecycle for one document:
|
|
// it loads any existing pending suggestions when the doc opens, then fires a
|
|
// fresh check 4s after the user stops typing. `checking` drives the breathing
|
|
// dot in the StatusBar. The server rate-limits per document, so a check that
|
|
// fires too soon simply returns the current set unchanged.
|
|
export function useCheckpoint(docId: string | null) {
|
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
|
const [checking, setChecking] = useState(false)
|
|
// True while a whole-document voice pass is in flight (explicit user action).
|
|
const [voicing, setVoicing] = useState(false)
|
|
// True while a whole-document collocation pass is in flight (explicit action).
|
|
const [collocating, setCollocating] = useState(false)
|
|
// True when the last LLM pass couldn't reach the model (server 502 or network
|
|
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
|
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
|
// next success or doc switch.
|
|
const [llmDown, setLlmDown] = useState(false)
|
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
// Pending auto-retry timer for a failed checkpoint (see runCheck).
|
|
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
const docIdRef = useRef(docId)
|
|
docIdRef.current = docId
|
|
|
|
// Token to discard responses from a doc we've since navigated away from.
|
|
const runRef = useRef(0)
|
|
|
|
// Backoff schedule for re-running a checkpoint that failed. A paste fires
|
|
// exactly ONE checkpoint, and nothing re-fires until the next keystroke — so a
|
|
// single transient failure (a momentary stall on the shared inference box)
|
|
// would otherwise strand the user on "resting" indefinitely after a paste.
|
|
// These delays clear the server's 30s per-doc floor by the later attempts, and
|
|
// a failed pass now releases its slot server-side so a retry can truly re-run.
|
|
const RETRY_DELAYS_MS = [3000, 12000, 35000]
|
|
|
|
const runCheck = useCallback(async (attempt = 0) => {
|
|
const id = docIdRef.current
|
|
if (!id) return
|
|
clearTimeout(retryRef.current)
|
|
const run = ++runRef.current
|
|
setChecking(true)
|
|
let retrying = false
|
|
try {
|
|
const fresh = await api.checkDoc(id)
|
|
if (run === runRef.current && id === docIdRef.current) {
|
|
setSuggestions(fresh)
|
|
setLlmDown(false)
|
|
}
|
|
} catch (err) {
|
|
console.error('checkpoint failed', err)
|
|
if (run !== runRef.current) return
|
|
if (attempt < RETRY_DELAYS_MS.length) {
|
|
// Keep trying quietly — don't flag "resting" until retries are exhausted.
|
|
retrying = true
|
|
retryRef.current = setTimeout(() => void runCheck(attempt + 1), RETRY_DELAYS_MS[attempt])
|
|
} else {
|
|
setLlmDown(true)
|
|
}
|
|
} finally {
|
|
// Stay in the "checking" state while a retry is queued so the breathing dot
|
|
// keeps reassuring rather than flickering off between attempts.
|
|
if (run === runRef.current && !retrying) setChecking(false)
|
|
}
|
|
}, [])
|
|
|
|
// runExplicitPass drives a whole-document, explicit-action pass (voice,
|
|
// collocation): it returns the unified pending set, so the other families'
|
|
// highlights survive, and shares the run token so navigating away discards a
|
|
// late response. It supersedes any queued grammar retry AND clears the
|
|
// checkpoint's "checking" state, so the breathing rose dot can't linger on
|
|
// after the retry timer it would have cleared is cancelled here.
|
|
const runExplicitPass = useCallback(
|
|
async (call: (id: string) => Promise<Suggestion[]>, setBusy: (b: boolean) => void, label: string) => {
|
|
const id = docIdRef.current
|
|
if (!id) return
|
|
// An explicit action fully supersedes a queued auto-check and grammar
|
|
// retry, and takes over the indicator — clear the stranded "checking" dot.
|
|
clearTimeout(debounceRef.current)
|
|
clearTimeout(retryRef.current)
|
|
setChecking(false)
|
|
const run = ++runRef.current
|
|
setBusy(true)
|
|
try {
|
|
const full = await call(id)
|
|
if (run === runRef.current && id === docIdRef.current) {
|
|
setSuggestions(full)
|
|
setLlmDown(false)
|
|
}
|
|
} catch (err) {
|
|
console.error(`${label} failed`, err)
|
|
if (run === runRef.current) setLlmDown(true)
|
|
} finally {
|
|
// setBusy is THIS invocation's own flag (a newer run sets its own), so
|
|
// clear it unconditionally — otherwise an overlapping pass that bumped
|
|
// the run token would strand this spinner on "Reading…" forever.
|
|
setBusy(false)
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
// Run the voice-consistency pass now (explicit "Check my voice" action).
|
|
const runVoice = useCallback(
|
|
() => runExplicitPass(api.voiceDoc, setVoicing, 'voice pass'),
|
|
[runExplicitPass],
|
|
)
|
|
|
|
// Run the collocation coach now (explicit "Make it sound natural" action).
|
|
const runCollocation = useCallback(
|
|
() => runExplicitPass(api.collocationDoc, setCollocating, 'collocation pass'),
|
|
[runExplicitPass],
|
|
)
|
|
|
|
// Call on every edit; schedules a check 4s after typing settles.
|
|
const schedule = useCallback(() => {
|
|
clearTimeout(debounceRef.current)
|
|
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
|
|
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
|
|
}, [runCheck])
|
|
|
|
// Load existing pending suggestions whenever the document changes, and cancel
|
|
// any in-flight debounce from the previous doc.
|
|
useEffect(() => {
|
|
clearTimeout(debounceRef.current)
|
|
clearTimeout(retryRef.current)
|
|
runRef.current++
|
|
setSuggestions([])
|
|
setChecking(false)
|
|
setVoicing(false)
|
|
setCollocating(false)
|
|
setLlmDown(false)
|
|
if (!docId) return
|
|
let cancelled = false
|
|
void (async () => {
|
|
try {
|
|
const existing = await api.listSuggestions(docId)
|
|
if (!cancelled && docIdRef.current === docId) setSuggestions(existing)
|
|
} catch (err) {
|
|
console.error('failed to load suggestions', err)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [docId])
|
|
|
|
useEffect(
|
|
() => () => {
|
|
clearTimeout(debounceRef.current)
|
|
clearTimeout(retryRef.current)
|
|
},
|
|
[],
|
|
)
|
|
|
|
// Drop one suggestion locally (after accept/dismiss) without a refetch.
|
|
const removeSuggestion = useCallback((id: string) => {
|
|
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
|
}, [])
|
|
|
|
return { suggestions, checking, voicing, collocating, llmDown, schedule, runVoice, runCollocation, removeSuggestion }
|
|
}
|