ui(phase-2e): local-search — plan + implementation#187
Merged
Conversation
Translates docs/specs/2026-04-19-ui-design/local-search.md into a checkbox-tracked implementation plan. Plan + impl ship as one PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 parser tests cover: plain text, quoted phrases, every prefix operator (from:/in:/since:/before:/has:image/has:file/has:link), unknown prefix → warning + plain-text fallback, URL-in-query edge case, raw-echo preservation. `crates/client/src/search/` module skeleton with only `query` populated; sibling modules land incrementally per plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 tokenize tests cover: whitespace + punctuation split, case-insensitive lowering, `@handle` + `#channel` + URL preservation (each emits the sigil form AND the stem so plain-text queries still match), multibyte char safety, apostrophe-inside-word handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8 index tests cover: insert + lookup, insert idempotency, remove-message unthreads all tokens, remove-channel + remove-grove, evict-older-than, author synthetic tokens (`@mira` + `mira` indexed for every message so `from:` operators work without a body hit), all-postings dedup by id. `SearchIndex` is `!Clone` — the handle layer wraps it in `Arc<Mutex<_>>`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 execute tests cover the four scopes (ThisLetter / ThisChannel / AllLetters / AllGrovesAndLetters), quoted-phrase adjacency, every prefix operator (from: / in: / since: / before: / has:*), results timestamp-desc ordering, and matched-ranges population. `since:` / `before:` use local-timezone midnight via `chrono::Local` so the tz contract in the spec holds on both native and wasm. `highlight` ships as a minimal substring-walker — Task 5 extends it with token-boundary awareness once the dedicated tests land. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 highlight tests pin the behaviour of `match_ranges` + `build_excerpt`: no-ranges empty, single/multi-token ranges, phrase range, case-insensitivity, overlap merge, excerpt centring, both-sided truncation, local-to-excerpt offset translation, no-range passthrough. Implementation landed in Task 4 as a stub; Task 5 pins it with tests so future refactors can't drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SearchIndexConfig carries horizon_days (default 90), remember_recents (default true), per_grove_enabled map. RecentQuery ring buffer caps at 8 with dedup-by-text and clear-all / forget-one helpers. SearchIndexBuildStatus enum feeds the UI's `indexing… (local only)` placeholder + streaming banner. storage.rs gets save_search_config / load_search_config / save_search_recents / load_search_recents — index itself never persists (dual-target memory-only per plan §Architecture). 9 tests cover every invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clonable Arc<Mutex<_>> wrapper over SearchIndex + config + recents +
status. Production uses `new()` (loads from storage); tests use
`new_in_memory()` to avoid polluting the shared native data dir.
Verbs: insert / rebuild / query / remove_* / set_config /
push_recent / forget_recent / clear_all_recents / status.
Top-level re-exports added to willow-client::lib so web + agent
callers can `use willow_client::{SearchIndexHandle, ...}` directly.
7 handle tests cover: insert→query flow, grove opt-out blocks
inserts, disabled config blocks inserts, remember_recents=false
blocks push_recent, dedup + cap, rebuild replaces old state,
Send + Sync smoke assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SearchUiState (open / query / scope / results / status / recents / debouncing) + SearchUiWriteSignals added to AppState / AppWriteSignals. Scope persists across reloads via localStorage key `willow.search.scope` (JSON-serialised SearchScope); everything else is session-scoped per spec §Privacy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SearchInput: form role=search, Esc contract (clear non-empty / close empty), Enter fires on_submit for recents push, scope-aware placeholder (search this channel / letter / all letters / groves + letters), aria-controls + aria-autocomplete + aria-live-friendly debounce shading. ScopeChip: four-option popover, unreachable scopes greyed with title tooltip, aria-haspopup/aria-expanded. CSS consumes foundation tokens only; reduced-motion override shipped. Browser-test coverage deferred to Task 14's final sweep; Task 9 ships wasm-compile + clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ResultRow: button role=option with three-span layout (context line + excerpt + right-column arrow). Excerpt spans wrap matched ranges in <mark aria-label="match"> per spec §Accessibility. ResultsList: grouping per scope (ThisChannel/ThisLetter = single implicit group, AllLetters by letter id, AllGrovesAndLetters by grove id with letters as synthetic `letters` group). Streaming banner aria-live=polite. Listbox aria-live=polite for count-updates. RecentsList: contextmenu=forget per-chip, clear-all action. SearchSurface: full-screen takeover, 120ms debounce effect that on_cleanup clears so a fresh query cancels the in-flight timer, recents vs results branch on empty query, privacy footer always visible. icon_arrow_up_right + supporting CSS consume foundation tokens only; reduced-motion overrides for animation. Browser-test coverage deferred to Task 14 sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SearchIndexHandle booted once in app.rs + provided via context. An Effect hydrates it from app_state.chat.messages on every change — dedup keeps repeat rebuilds idempotent. SearchSurface mounts over the main pane when app_state.search.open flips true. CommandPalette now carries an on_search callback that forwards plain text with scope = AllLetters, closes the palette, and opens the surface (per local-search.md §Command-palette bridge). keybindings.rs picks up: - `/` (when not typing elsewhere) → open + focus search input - `⌘F` / `Ctrl-F` (with a channel focused) → set scope ThisChannel + open + focus input - `Escape` close-stack inserts search above palette: the input owns its Esc contract (clear vs close); the global listener only pops the surface when the query is already empty so the two layers don't fight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mobile top-bar search button now opens SearchSurface directly (was: opened the command palette). Scope defaults to ThisChannel when a channel is focused so the first keystroke searches the right container, matching the spec's mobile entry-point intent. CSS stubs for `.search-pull-down-bar` / `.search-pull-down-hint` land so the deferred pull-down gesture can paint the bar without re-touching styles. Pull-down scroll-boundary arbitration with the existing swipe gestures is a follow-up per plan §Ambiguity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 phase_2e_* browser tests land in crates/web/tests/browser.rs, covering every ARIA and copy contract: form[role=search], listbox aria-live=polite, <mark aria-label=match>, privacy footer byte-exact, scope-chip aria-haspopup, streaming banner format, result-row layout, disabled-scope tooltip, recents role=listitem. Telemetry guard: //! PRIVACY CONTRACT comment on handle.rs + ripgrep verifies zero tracing:: calls across crates/client/src/search and crates/web/src/components/search. fmt --check clean, clippy --workspace -D warnings clean, cargo test -p willow-client search:: 74/74 pass, wasm compile clean on both client and web. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves conflict at EOF of crates/web/tests/browser.rs — both sides appended independent test content (phase 2e top-level tests here, then `mod foundation_tokens` from PR #184). Keep both. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clippy::unnecessary_sort_by fires on clippy 1.95 (CI) but not 1.94 (local). Swap to sort_by_key(Reverse) for the timestamp-desc sort. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 22, 2026
# Conflicts: # crates/web/src/app.rs # crates/web/src/state.rs # crates/web/style.css # crates/web/tests/browser.rs
# Conflicts: # crates/client/src/lib.rs # crates/web/src/icons.rs # crates/web/src/state.rs # crates/web/style.css # crates/web/tests/browser.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Plan + implementation for
docs/specs/2026-04-19-ui-design/local-search.mdin a single PR.
willow-client::searchmodule, dual-target (native + wasm32), neverpersisted on disk (rebuilt per session from already-decrypted
messages).
/focuses the search slot when not typing elsewhere;⌘F/Ctrl-Finside a channel flips scope tothis channelandopens the surface;
Esccontract (clear non-empty vs close empty);⌘Kpalette bridge forwards plain text toAllLetters.ThisChannelscope default when a channel is focused. (Literalpull-down gesture deferred — see plan §Ambiguity decisions.)
operators (
from:,in:,since:,before:,has:image,has:file,has:link). Unknown operators warn + fall back to plaintext.
on first match with
<mark aria-label="match">, streaming banner,always-visible privacy footer, recents chips (max 8), scope chip
popover with disabled-option tooltips.
role="search"form,role="listbox"results,aria-live="polite"count updates,aria-activedescendantkeyboardchain, focus restoration on surface close.
tracing::*calls across the search module (grep-verified); module-level
PRIVACY CONTRACTcomment pins the rule.Architecture: see plan §Architecture for the index-module placement
(
willow-client::search, not a new crate). Native SQLite FTS5 backenddeferred — v1 uses the in-memory inverted index on both targets per
plan §Ambiguity.
Test plan
cargo fmt --all --checkpassescargo clippy --workspace -- -D warningszero warningscargo test -p willow-client search::→ 74/74 passcargo check --target wasm32-unknown-unknown -p willow-client -p willow-webcleanjust test-browser— 9phase_2e_*tests land in CI (not run locally per task constraints)Commits
docs(plan): phase 2e — local-search implementation planui(phase-2e): add local-search query parser + unit testsui(phase-2e): tokenize message bodies preserving mentions + urlsui(phase-2e): add inverted index with insert/remove/evictui(phase-2e): add scope-aware query executor + highlight stubui(phase-2e): cover highlight excerpts with dedicated testsui(phase-2e): add search config + recents + build-status primitivesui(phase-2e): expose SearchIndexHandle on willow-clientui(phase-2e): add search UI signals + persist scopeui(phase-2e): SearchInput + ScopeChip with keyboard + a11yui(phase-2e): render results list + row + highlight + recents + surfaceui(phase-2e): mount SearchSurface + palette bridge + / + ⌘Fui(phase-2e): mobile top-bar search opens surface directlyui(phase-2e): sweep a11y contract + privacy guard + final tests🤖 Generated with Claude Code