Phase 10: organization & polish — cross-doc search, tags, touch, warm failures

Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.

Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.

Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.

Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).

Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 06:29:56 -07:00
parent 60eba25fee
commit 9e141e4169
21 changed files with 1841 additions and 51 deletions

View File

@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
const [checking, setChecking] = useState(false)
// True while a whole-document voice pass is in flight (explicit user action).
const [voicing, setVoicing] = 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)
const docIdRef = useRef(docId)
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
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) setLlmDown(true)
} finally {
if (run === runRef.current) setChecking(false)
}
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
const full = await api.voiceDoc(id)
if (run === runRef.current && id === docIdRef.current) {
setSuggestions(full)
setLlmDown(false)
}
} catch (err) {
console.error('voice pass failed', err)
if (run === runRef.current) setLlmDown(true)
} finally {
if (run === runRef.current) setVoicing(false)
}
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
setSuggestions([])
setChecking(false)
setVoicing(false)
setLlmDown(false)
if (!docId) return
let cancelled = false
void (async () => {
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
setSuggestions((prev) => prev.filter((s) => s.id !== id))
}, [])
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
}

63
web/src/hooks/useTags.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react'
import { api, type Tag, type TagColor } from '../api/client'
// useTags owns the user's tag roster (the full set the writer has created, with
// per-tag document counts). Document↔tag assignments live in App alongside the
// document list; this hook is just the roster plus create/recolor/delete and a
// refresh used to keep counts current after an assignment changes.
export function useTags() {
const [tags, setTags] = useState<Tag[]>([])
const refresh = useCallback(async () => {
try {
setTags(await api.listTags())
} catch (err) {
console.error('failed to load tags', err)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
// Create (or reuse) a tag, returning it. The roster is refreshed so the new
// tag appears with a zero count.
const createTag = useCallback(
async (name: string, color: TagColor): Promise<Tag | null> => {
try {
const tag = await api.createTag(name, color)
await refresh()
return tag
} catch (err) {
console.error('failed to create tag', err)
return null
}
},
[refresh],
)
const recolorTag = useCallback(
async (id: string, color: TagColor) => {
// Optimistic recolor; refresh reconciles.
setTags((prev) => prev.map((t) => (t.id === id ? { ...t, color } : t)))
try {
await api.updateTag(id, { color })
} catch (err) {
console.error('failed to recolor tag', err)
void refresh()
}
},
[refresh],
)
const deleteTag = useCallback(async (id: string) => {
setTags((prev) => prev.filter((t) => t.id !== id))
try {
await api.deleteTag(id)
} catch (err) {
console.error('failed to delete tag', err)
}
}, [])
return { tags, refresh, createTag, recolorTag, deleteTag }
}