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,26 +1,71 @@
import type { DocSummary } from '../../api/client'
import { useMemo, useState } from 'react'
import type { DocSummary, Tag, TagColor } from '../../api/client'
import { DocListItem } from './DocListItem'
import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
interface Props {
docs: DocSummary[]
roster: Tag[]
selectedId: string | null
onSelect: (id: string) => void
onCreate: () => void
onDelete: (id: string) => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
}
// DocList is the 260px sidebar: the document browser plus a New button.
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
// button, and the document browser (each row showing its tag chips).
export function DocList({
docs,
roster,
selectedId,
onSelect,
onCreate,
onDelete,
onToggleTag,
onCreateTag,
}: Props) {
// Active tag filter (null = show all). Cleared automatically if the tag
// disappears from the roster.
const [filterId, setFilterId] = useState<string | null>(null)
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
const filtered = useMemo(
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
[docs, activeFilter],
)
// Only surface tags that are actually in use, so the filter bar stays tidy.
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
return (
<aside
className="flex h-full w-[260px] flex-col gap-2 p-3"
className="flex h-full w-[280px] flex-col gap-2 p-3"
style={{ borderRight: '1px solid var(--color-border)' }}
>
<SearchBox onSelect={onSelect} />
{usedTags.length > 0 && (
<div className="flex flex-wrap gap-1 pb-0.5">
{usedTags.map((t) => (
<TagChip
key={t.id}
tag={t}
count={t.doc_count}
active={activeFilter === t.id}
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
/>
))}
</div>
)}
<button
type="button"
onClick={onCreate}
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
</button>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
{docs.length === 0 ? (
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
No documents yet.
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
</p>
) : (
docs.map((doc) => (
filtered.map((doc) => (
<DocListItem
key={doc.id}
doc={doc}
active={doc.id === selectedId}
roster={roster}
onSelect={() => onSelect(doc.id)}
onDelete={() => onDelete(doc.id)}
onToggleTag={onToggleTag}
onCreateTag={onCreateTag}
/>
))
)}