Skip to content

Commit 92f520f

Browse files
committed
docs(specs): revise v0.6.2 spec per subagent review (4 Important, 4 Minor)
Reviewer subagent findings applied: - I-1: hitRanks added to State block (was referenced but undeclared) - I-2: SearchBar internal api.search(buildFtsQuery(raw), 500) + three fire cases for onQueryChange documented - I-3: composition snapshot case 5 added (searchHits = empty Set) - I-4: explicit handleSearchResult removal + badge removal listed - M-1: containerRef + searching + outside-click useEffect removals - M-3: searchActive definition consolidated (searchHits !== null) - M-5: sunse* prefix example added - M-7: stale-search-after-watcher risk documented - M-8: (foo OR bar) sanitizer behavior clarified (parens stripped pre-tokenisation; OR becomes literal token) - M-4, M-6: vague process note + outdated test comment rephrased Spec now ready for writing-plans. Subagent re-review deferred since all findings were applied verbatim from the first-pass review.
1 parent bc451d8 commit 92f520f

1 file changed

Lines changed: 66 additions & 22 deletions

File tree

docs/superpowers/specs/2026-04-17-v0.6.2-live-faceted-search-design.md

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@ redundant dropdown preview.
4141
wildcards only.
4242
- **Facet counts across the full corpus.** Counts reflect the visible
4343
result set only (client-side compute from the loaded `files` array).
44-
Tracked as a follow-up optimization; file a `needs-web-search` issue
45-
if users complain.
44+
Tracked as a follow-up issue (file at implementation time).
4645
- **List cap pagination.** The 100-row cap on `listFilesWithTags` is
4746
an existing limitation, not introduced here. Files outside the cap
4847
are not surfaced by search. Tracked as a separate follow-up issue.
@@ -106,13 +105,19 @@ searchHash: string | null // was v0.6.1's drill-down hack
106105

107106
// NEW:
108107
searchQuery: string // raw user input, lifted from SearchBar
109-
searchHits: Set<string> | null // blake3-hash set of matching files; null when no search
110-
// Set (not array) for O(1) intersection
108+
searchHits: Set<string> | null // blake3-hash set of matching files;
109+
// null = no search active (show all / tag-filter only)
110+
// empty Set = search active but zero matches (show empty list)
111+
// Set (not array) for O(1) intersection.
112+
hitRanks: Map<string, number> // hash → BM25 rank; populated alongside searchHits.
113+
// Used by sortByRank; new Map() when cleared.
111114

112115
// DERIVED (not state; recomputed each render):
113116
visibleFiles: FileWithTags[] // composeVisible(files, selectedTagId, searchHits)
114117
facetCounts: Record<string, number> // computeFacets(visibleFiles)
115-
searchActive: boolean // searchQuery.trim().length >= 2 && searchHits !== null
118+
searchActive: boolean // searchHits !== null
119+
// (the MIN_QUERY_LEN=2 guard lives in SearchBar; by the
120+
// time App sees a non-null searchHits, the guard passed)
116121
```
117122

118123
### Components
@@ -147,16 +152,20 @@ export function sortByRank(
147152
- If the whole input is already wrapped in double-quotes → pass through
148153
verbatim (phrase query).
149154
- If the input ends with `*` → treat as prefix query, split tokens,
150-
wrap last token as `"prefix"*`.
155+
wrap last token as `"prefix"*`. Example: `sunse*``"sunse"*`.
151156
- Otherwise: split on whitespace, quote each non-empty token, join with
152157
space (FTS5 implicit AND).
153158
- `sunset photos``"sunset" "photos"`
154159
- `it's fine``"it's" "fine"` (apostrophes safe inside quotes)
155160
- `file/path.ext``"file/path.ext"` (slashes + dots OK in quotes)
156-
- Strip any characters that remain parse-unsafe (`"` inside a token, raw
157-
parentheses, `-` at the start of a token → FTS5 negation).
161+
- Strip any characters that remain parse-unsafe BEFORE tokenisation:
162+
bare `"` (unpaired), raw `(` / `)`, and leading `-` on a token
163+
(FTS5 negation). Then tokenise and quote as above.
158164
- Honest subset: phrase + prefix + implicit AND. Everything else
159165
is quoted away.
166+
- Example: `(foo OR bar)` → parens stripped → tokens `foo`, `OR`,
167+
`bar` → output `"foo" "OR" "bar"` (three implicit-AND tokens;
168+
FTS5 OR keyword is lost, but input doesn't parse-error).
160169

161170
**`computeFacets` reducer:**
162171
```typescript
@@ -196,14 +205,26 @@ export function composeVisible(
196205

197206
**Changes from v0.6.1:**
198207
- Remove dropdown render (the `<div role="listbox">` block entirely).
199-
- Remove local `hits: SearchHit[] | null` and `open: boolean` state.
208+
- Remove local `hits: SearchHit[] | null`, `open: boolean`, and
209+
`searching: boolean` state.
210+
- Remove `containerRef` + the outside-click `useEffect` that closed
211+
the dropdown.
200212
- Remove `handleHitClick` and `onResultClick` prop.
201213
- Keep: debounce, MIN_QUERY_LEN=2 guard, clear-button, error swallow.
202214
- New prop: `onQueryChange(query: string, hits: SearchHit[] | null)`.
203215
App.tsx uses this to lift `searchQuery` + the hash-set derived from
204216
`hits` into App state.
205-
- On query clear (input emptied): fire `onQueryChange("", null)` so App
206-
can restore prior sort + full sidebar.
217+
- **SearchBar still calls `api.search(buildFtsQuery(raw), 500)`
218+
internally** — the sanitizer + IPC stay co-located with the input.
219+
After the debounce resolves, SearchBar calls
220+
`onQueryChange(raw, hits)` to lift the result upward. (Limit raised
221+
from 50 → 500 to feed the in-list re-sort.)
222+
- **Three fire-cases for `onQueryChange`:**
223+
1. User typed ≥ MIN_QUERY_LEN and debounce resolved →
224+
`onQueryChange(raw, hits)` (hits may be `[]` if nothing matched).
225+
2. User typed < MIN_QUERY_LEN (1 char, or cleared to empty) →
226+
`onQueryChange("", null)` so App resets `searchHits` to null.
227+
3. User clicked clear button → `onQueryChange("", null)`.
207228

208229
```typescript
209230
interface SearchBarProps {
@@ -255,8 +276,15 @@ interface TagSidebarProps {
255276
#### `apps/desktop/src/App.tsx` (MODIFIED)
256277

257278
**State changes:**
258-
- Remove `searchHash: string | null`.
259-
- Add `searchQuery: string` and `searchHits: Set<string> | null`.
279+
- Remove `searchHash: string | null` (line 52 of current App.tsx).
280+
- Remove `handleSearchResult` function (lines 179–182 of current
281+
App.tsx) — it's the exact source of #25 (`setSelectedTagId(null)`).
282+
- Remove the `✕ search` badge JSX block from the header (lines
283+
190–199 of current App.tsx); the SearchBar's own clear button
284+
replaces it.
285+
- Add `searchQuery: string`, `searchHits: Set<string> | null`, and
286+
`hitRanks: Map<string, number>` state (all three initialized as
287+
`""`, `null`, `new Map()`).
260288

261289
**Effect on mount (unchanged):** `listFilesWithTags(100)` + `listTags()`.
262290

@@ -287,9 +315,10 @@ const sidebarTotalCount = searchActive ? visibleFiles.length : files.length;
287315
```
288316

289317
**Header JSX change:**
290-
- Remove the `✕ search` badge (no longer needed; SearchBar's own clear
291-
button handles it).
292-
- `<SearchBar onQueryChange={handleSearchChange} />`
318+
- Replace `<SearchBar onResultClick={handleSearchResult} />` with
319+
`<SearchBar onQueryChange={handleSearchChange} />`.
320+
- (The `✕ search` badge removal was already listed above under State
321+
changes.)
293322

294323
**Sidebar JSX change:**
295324
- `<TagSidebar ... mode={sidebarMode} totalCount={sidebarTotalCount} counts={facetCounts} />`
@@ -324,8 +353,9 @@ const sidebarTotalCount = searchActive ? visibleFiles.length : files.length;
324353
- input with slash/dot `a/b.jpg``"a/b.jpg"`
325354
- empty / whitespace-only → `""` (SearchBar's MIN_QUERY_LEN guard
326355
blocks upstream; test anyway)
327-
- adversarial: `"` alone → stripped; `(foo OR bar)` → quoted
328-
`"(foo" "OR" "bar)"` (degrades to plain tokens)
356+
- adversarial: bare `"` stripped → empty output; `(foo OR bar)`
357+
parens stripped → `"foo" "OR" "bar"` (OR keyword becomes a
358+
literal token; documented limitation)
329359
- `computeFacets`:
330360
- empty files → `{}`
331361
- file with no tags → `{}`
@@ -358,18 +388,21 @@ const sidebarTotalCount = searchActive ? visibleFiles.length : files.length;
358388
- New: `mode="facets" All row count reflects sum of counts`.
359389
- Existing tests still pass with `mode="all"` default.
360390
- `App.test.tsx` (MODIFIED):
361-
- Existing file-event debounce test: keep, but change mock expectations
362-
— no more `list_files_with_metadata`; just `list_files_with_tags`
363-
and `list_tags` (already updated in Task 6 / commit `cd55627`).
391+
- Existing file-event debounce test: already correct in current code
392+
(mock expectations updated in `cd55627`); no change needed.
364393
- Existing watcher-banner test: keep as-is.
365394
- `App.compose.test.tsx` (NEW): **composition snapshot test**. Given
366395
fixture inputs (files, tagId, hits), assert visibleFiles matches an
367-
expected snapshot. Pins the exact bug that shipped in v0.6.1. Four
396+
expected snapshot. Pins the exact bug that shipped in v0.6.1. Five
368397
cases:
369398
1. No search, no tag → all files.
370399
2. Tag filter only → tag-narrowed.
371400
3. Search only → hit-narrowed, sorted by rank.
372401
4. Search + tag → intersected, sorted by rank (the invariant #25 broke).
402+
5. Search active but zero hits (`searchHits = new Set()`) → empty
403+
visible list, sidebarMode = "facets", facetCounts = `{}`
404+
(empty-state branch). Distinct from case 1 (`searchHits = null`,
405+
sidebarMode = "all").
373406

374407
### No Playwright; no Tauri IPC integration test changes (search command's `search_inner` smoke test already exists from I8 fix).
375408

@@ -409,6 +442,17 @@ separate `fix(desktop): ...` commits if needed.
409442
- **Escape only clearing input (v0.6.2) will feel inconsistent when
410443
the detail view lands** (#27). Explicit v0.6.2 limitation; #28 fixes
411444
it holistically.
445+
- **Stale search hits after file-watcher refresh.** `searchHits` is
446+
not automatically re-queried when the watcher fires a list refresh
447+
(the existing 300ms debounced `listFilesWithTags(100)` reload).
448+
After a watcher-triggered reload, if the user was searching, their
449+
`searchHits` set still reflects the pre-reload corpus — so new
450+
files matching the query won't appear, and deleted files will be
451+
filtered out by the missing hash. User-visible as "the list isn't
452+
updating even though I know I just added a file." Mitigation for
453+
v0.6.2: documented; user can retype to re-fire. Post-v1 fix: the
454+
file-event effect in App.tsx re-calls `buildFtsQuery` + `api.search`
455+
alongside the `listFilesWithTags` refresh when a search is active.
412456

413457
## Decisions and why
414458

0 commit comments

Comments
 (0)