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

@@ -1,11 +1,34 @@
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
// Vite proxies to the Go server in dev and the binary serves directly in prod.
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
// backend TagColor* constants; unknown values render as rose.
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
export interface Tag {
id: string
name: string
color: TagColor
doc_count?: number // present only in the tag-roster listing
}
export interface DocSummary {
id: string
title: string
word_count: number
updated_at: string
tags: Tag[]
}
// One cross-document search hit. `snippet` is plain text with the matched span
// wrapped in the  sentinels (see splitSnippet) for highlighting.
export interface SearchResult {
id: string
title: string
word_count: number
updated_at: string
snippet: string
tags: Tag[]
}
export interface Document {
@@ -155,11 +178,78 @@ export const api = {
body: JSON.stringify({ text, style }),
}),
// Cross-document full-text search (title + body). Returns hits with a
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
// Tags. listTags returns the roster with per-tag document counts; createTag is
// idempotent on name (returns the existing tag if it already exists);
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
// assignTag/unassignTag link a tag to one document.
listTags: () => req<Tag[]>('/tags'),
createTag: (name: string, color: TagColor) =>
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
assignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
unassignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
// Current deployed build id — changes whenever a new frontend ships. The
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
}
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
// by splitSnippet to render the matched span highlighted.
export const SNIPPET_HL_START = String.fromCharCode(1)
export const SNIPPET_HL_END = String.fromCharCode(2)
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
// UI can bold the matched span(s) without dangerously setting innerHTML.
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
const out: { text: string; hit: boolean }[] = []
let rest = snippet
for (;;) {
const start = rest.indexOf(SNIPPET_HL_START)
if (start < 0) {
if (rest) out.push({ text: rest, hit: false })
break
}
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
if (end < 0) {
// Unterminated (shouldn't happen) — emit the remainder plainly.
out.push({ text: rest.slice(start + 1), hit: false })
break
}
out.push({ text: rest.slice(start + 1, end), hit: true })
rest = rest.slice(end + 1)
}
return out
}
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
// back to the rose accent, matching the backend's coercion.
export function tagColorVar(color: TagColor | string): string {
switch (color) {
case 'mint':
return 'var(--color-mint)'
case 'peach':
return 'var(--color-peach)'
case 'lavender':
return 'var(--color-lavender)'
case 'sky':
return 'var(--color-sky)'
case 'honey':
return 'var(--color-honey)'
default:
return 'var(--color-accent)'
}
}
// One turn in an Ask Petal conversation. History lives only in the component —
// the server is stateless and re-injects the suggestion context every request.
export interface ChatMessage {