Skip to content

ui(phase-2e): local-search — plan + implementation#187

Merged
intendednull merged 20 commits into
mainfrom
phase-2e/local-search
Apr 25, 2026
Merged

ui(phase-2e): local-search — plan + implementation#187
intendednull merged 20 commits into
mainfrom
phase-2e/local-search

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Summary

Plan + implementation for docs/specs/2026-04-19-ui-design/local-search.md
in a single PR.

  • On-device, encrypted-at-rest search index (scope-aware) — new
    willow-client::search module, dual-target (native + wasm32), never
    persisted on disk (rebuilt per session from already-decrypted
    messages).
  • Desktop: / focuses the search slot when not typing elsewhere;
    ⌘F / Ctrl-F inside a channel flips scope to this channel and
    opens the surface; Esc contract (clear non-empty vs close empty);
    ⌘K palette bridge forwards plain text to AllLetters.
  • Mobile: top-bar search button opens the surface directly with
    ThisChannel scope default when a channel is focused. (Literal
    pull-down gesture deferred — see plan §Ambiguity decisions.)
  • Query language: plain tokens + quoted phrases + 7 prefix
    operators (from:, in:, since:, before:, has:image,
    has:file, has:link). Unknown operators warn + fall back to plain
    text.
  • Results surface: grouped per-scope, three-line excerpt centred
    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.
  • A11y: role="search" form, role="listbox" results,
    aria-live="polite" count updates, aria-activedescendant keyboard
    chain, focus restoration on surface close.
  • Privacy: no tracing::* calls across the search module (grep-
    verified); module-level PRIVACY CONTRACT comment pins the rule.

Architecture: see plan §Architecture for the index-module placement
(willow-client::search, not a new crate). Native SQLite FTS5 backend
deferred — v1 uses the in-memory inverted index on both targets per
plan §Ambiguity.

Test plan

  • cargo fmt --all --check passes
  • cargo clippy --workspace -- -D warnings zero warnings
  • cargo test -p willow-client search:: → 74/74 pass
  • cargo check --target wasm32-unknown-unknown -p willow-client -p willow-web clean
  • just test-browser — 9 phase_2e_* tests land in CI (not run locally per task constraints)

Commits

  • docs(plan): phase 2e — local-search implementation plan
  • ui(phase-2e): add local-search query parser + unit tests
  • ui(phase-2e): tokenize message bodies preserving mentions + urls
  • ui(phase-2e): add inverted index with insert/remove/evict
  • ui(phase-2e): add scope-aware query executor + highlight stub
  • ui(phase-2e): cover highlight excerpts with dedicated tests
  • ui(phase-2e): add search config + recents + build-status primitives
  • ui(phase-2e): expose SearchIndexHandle on willow-client
  • ui(phase-2e): add search UI signals + persist scope
  • ui(phase-2e): SearchInput + ScopeChip with keyboard + a11y
  • ui(phase-2e): render results list + row + highlight + recents + surface
  • ui(phase-2e): mount SearchSurface + palette bridge + / + ⌘F
  • ui(phase-2e): mobile top-bar search opens surface directly
  • ui(phase-2e): sweep a11y contract + privacy guard + final tests

🤖 Generated with Claude Code

intendednull and others added 16 commits April 21, 2026 14:30
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>
intendednull and others added 2 commits April 21, 2026 15:41
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>
# 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
@intendednull intendednull merged commit cdd7892 into main Apr 25, 2026
5 checks passed
@intendednull intendednull deleted the phase-2e/local-search branch April 25, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant