From 06c5e5cd2d98f538b57f9209827c2e47bfaf2f17 Mon Sep 17 00:00:00 2001 From: xarmian Date: Tue, 12 May 2026 00:46:55 -0400 Subject: [PATCH] feat(web): CommandPalette uses localSearch (TASK-1365) (#511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): CommandPalette uses localSearch (TASK-1365) Phase 3c of the local-first read model (PLAN-1343 / DOC-1342): wire the global CommandPalette / top-bar search to localSearch. Behavior - Default scope: search the current workspace's in-memory MiniSearch index synchronously on every keystroke. No network round-trip, no 200ms debounce — sub-millisecond typing. - "All workspaces" toggle: when on, also search every other workspace whose localIndex is `'ready'` (i.e. already hydrated this session). Results from every ready workspace are merged by score, ties broken by `updated_at DESC`. Toggle state persists to localStorage so it survives reloads. Hidden when only one workspace is ready (no point showing a no-op toggle). - Cross-workspace navigation: each local result carries its source workspace slug + owner_username so `selectResult` navigates to the right route — `selectResult` falls back to `workspaceStore.current` for server hits. - Server fallback paths: * `body:` / `content:` queries — local index doesn't hold the rich-text body, so server FTS is the only way to grep. * No ready workspaces yet (cold session) — falls through to `api.search` so the palette still works pre-bootstrap. - Reactive: a single `$effect` watches `query`, `searchAllWorkspaces`, filter chips, every workspace's `localSearch.epoch`, and the current workspace's bootstrap state — so SSE-driven upserts and hot toggle flips re-rank without manual `doSearch()` calls. The `oninput={doSearch}` handler is removed; reactivity does the work. - Drops `result-count`, `loadMore`, and facets on the local path — local results aren't paginated (everything's in RAM, capped at `PAGE_SIZE * 2` for display); facets are a server-only feature. Parent: PLAN-1343. * fix(web): hide Load more on local search path per Codex review (round 1) Codex round 1 P2: `total` was set to the pre-slice `filtered.length` while `results` was capped at `PAGE_SIZE * 2`. That made the "Load more" affordance show when local matches exceeded the cap — and clicking it would call `api.search`, which injects single-workspace server-FTS rows into the local (potentially cross-workspace) result set. Set `total = results.length` on the local path so the affordance stays hidden. Local results are all in RAM; if the result count exceeds the display cap the right answer is a tighter query, not a paginated server fetch. * fix(web): current-workspace fallback + filter chip persistence per Codex review (round 2) Codex round 2 P2 #1: with `searchAllWorkspaces` on, if the current workspace was still bootstrapping but any OTHER workspace was ready, `ready.length > 0` sent the search down the local-only path — omitting current-workspace results entirely. The toggle is meant to widen the search, never to replace the current workspace. Add an explicit `currentReady` check: only take the local path when the current workspace is ready. Otherwise fall through to the server (which will return the current workspace's results too). Codex round 2 P2 #2: filter chips were gated on `facets` (server only). Switching from server → local with a filter active would hide the chip but keep the filter applied. Add a fallback row that shows the active chip(s) on the local path so they're visible and clearable. * fix(web): stale-response guard + status filter under-fill per Codex review (round 3) Codex round 3 P2 #1: server search responses had no stale-response guard. A request started while `currentReady` was false (or for a `body:` query) could return after the local path had already rendered and clobber local results — including reintroducing `total > results.length` and the Load more button. Snapshot the query + `searchAllWorkspaces` flag at dispatch; only apply the response if both still match. The same guard protects the catch and finally branches. Codex round 3 P2 #2: local status filtering was applied AFTER the per-workspace `localSearch.search(... limit: 20)` cap, so a status chip could under-fill or empty the result set even when matching items existed beyond the cap. Expand the per-workspace pull by 5x when `filterStatus` is active so the post-fetch filter has headroom. (The collection filter doesn't need this because it's passed directly to `localSearch.search`, which filters inside the index walk.) * fix(web): full-scope stale-response guard per Codex review (round 4) Codex round 4 P2: the R3 stale-response guard only snapshotted `query` and `searchAllWorkspaces`. An in-flight server response could still clobber newer local results after `currentReady` flipped to true, or overwrite results after a filter chip changed with the same query. Snapshot the full dispatch scope (query, toggle, filter chips, current workspace slug, currentReady-vs-body branch) at request time and gate every `results = ...` site on `isSameDispatch()`. Once the current workspace hydrates, a server response from a `!currentReady` snapshot is no longer authoritative. * fix(web): read live currentReady + guard loadMore per Codex review (round 5) Codex round 5 P2 #1: my R4 `isSameDispatch` captured `currentReady` at dispatch time, so the check stayed stale once the index hydrated mid-request — the cold server response still matched and clobbered the local results that the readiness effect had just produced. Switch to reading live state via `localIndex.bootstrapStateFor(snapshotWsSlug)` inside the guard; captured `snapshotCurrentReady` is removed. Codex round 5 P2 #2: `loadMore` only snapshotted query/filters. A server-page request in flight could append rows after the scope changed (current workspace hydrated, all-workspaces toggle flipped). Add the same full-scope guard — query, toggle, both filter chips, workspace slug, and live-readiness — and bail if any has shifted. * fix(web): loadMore handles body: prefix correctly per Codex review (round 6) Codex round 6 P2: `loadMore` was sending the raw `query` to `api.search`, so page-2 of a `body:foo` search hit the server with the literal `body:foo` token. Worse, the live-readiness guard from R5 dropped body: page-2 responses whenever the current workspace was ready — but body: searches NEED the server (the local index doesn't carry content), so they should bypass that guard. Parse the query in `loadMore` and send the stripped `parsed.text` when the body prefix is present. Body queries are now exempt from the live-readiness drop; only non-body server pagination needs to worry about the path swap. * fix(web): body queries skip local-index dependency tracking per Codex review (round 7) Codex round 7 P2: the search-dispatch effect always tracked `localIndex.bootstrapStateFor` and `localSearch.epoch` for every workspace, including for body: queries. An SSE-driven epoch bump or hydration completion mid-flight would re-fire doSearch from offset 0 and wipe an in-flight `loadMore` append on the body: path. Gate the local-index dependency reads on `!parsed.body`. Body queries hit server FTS exclusively (the local index doesn't carry content), so their result set is unaffected by client-side mutations; skipping the tracking eliminates the loadMore race without losing incremental-update behavior for local searches. * fix(web): short-circuit local-state reads in doSearch for body queries per Codex review (round 8) Codex round 8 P2: even after R7 made the `$effect` skip explicit localIndex/epoch reads for body queries, `doSearch()` still synchronously called `readyWorkspaces()` and `localIndex.bootstrapStateFor()` BEFORE branching on `parsed.body`. Those reads register as reactive dependencies of the caller, so a mid-flight SSE bump or hydration completion would still re-fire doSearch and clobber an in-flight body: `loadMore` append. Reorder doSearch: parse first, then short-circuit both `currentReady` and `readyWorkspaces()` to constants when `parsed.body` is true. Body queries are server-authoritative; nothing in local state can change their result set, so they pay no reactivity tax on the local index. * fix(web): bare body:/content: queries no-op per Codex review (round 9) Codex round 9 P3: bare `body:` / `content:` with no following text was falling back to the raw query, shipping the literal `body:` token to `/search`. `loadMore` had the same fallback. Short-circuit both paths: when `parsed.body` is true and `parsed.text` is empty, clear results / bail. There's nothing useful to search for until the user keeps typing. --- .../components/search/CommandPalette.svelte | 512 ++++++++++++++++-- 1 file changed, 475 insertions(+), 37 deletions(-) diff --git a/web/src/lib/components/search/CommandPalette.svelte b/web/src/lib/components/search/CommandPalette.svelte index f1a38d5c..ef6c09fa 100644 --- a/web/src/lib/components/search/CommandPalette.svelte +++ b/web/src/lib/components/search/CommandPalette.svelte @@ -3,19 +3,49 @@ import { api } from '$lib/api/client'; import { workspaceStore } from '$lib/stores/workspace.svelte'; import { collectionStore } from '$lib/stores/collections.svelte'; + import { localIndex } from '$lib/stores/localIndex.svelte'; + import { localSearch, parseSearchQuery } from '$lib/stores/localSearch.svelte'; import { uiStore } from '$lib/stores/ui.svelte'; - import type { SearchResult, SearchFacets, SearchFilters } from '$lib/types'; + import type { + SearchResult, + SearchFacets, + SearchFilters, + Item, + ItemIndexRow, + } from '$lib/types'; import { getFieldValue, itemUrlId, formatItemRef } from '$lib/types'; import { relativeTime } from '$lib/utils/markdown'; const RECENT_SEARCHES_KEY = 'pad-recent-searches'; const MAX_RECENT = 10; const PAGE_SIZE = 20; + // Cross-workspace results merged from N ready workspaces — cap the + // fan-out so an extreme power user with 50 hydrated workspaces + // doesn't pay a 50× O(matching-docs) cost per keystroke. 20 is the + // max display anyway (PAGE_SIZE), but pulling the top 60 across + // workspaces (20 each from top 3) gives enough headroom for the + // merge step to keep representative coverage. PLAN-1343 / TASK-1365. + const LOCAL_PER_WS_LIMIT = 20; + + // Augmented result — adds the source workspace so cross-workspace + // navigation lands on the right URL. For results returned by the + // server `/search` endpoint (single-workspace path), `workspace` + // stays undefined and `selectResult` falls back to + // `workspaceStore.current`. For local results, the workspace is + // resolved from `workspaceStore.workspaces`. + interface AugmentedSearchResult extends SearchResult { + workspace?: { slug: string; owner_username: string }; + } let query = $state(''); - let results = $state([]); + let results = $state([]); let total = $state(0); let facets = $state(undefined); + // `searchAllWorkspaces` toggle: when on, search every workspace + // whose `localIndex` is `'ready'` in addition to the current one. + // Non-ready workspaces fall back to server search transparently. + // Stored in localStorage so the toggle survives reloads. + let searchAllWorkspaces = $state(loadAllWorkspacesPref()); // -1 means "no result armed". The user must press an arrow key (or type a // bare number) before Enter will navigate. See BUG-864. let selectedIdx = $state(-1); @@ -93,9 +123,9 @@ }; }); - function buildFilters(offset = 0): SearchFilters { + function buildFilters(offset = 0, wsSlug?: string): SearchFilters { const filters: SearchFilters = { - workspace: workspaceStore.current?.slug, + workspace: wsSlug ?? workspaceStore.current?.slug, limit: PAGE_SIZE, offset }; @@ -104,9 +134,68 @@ return filters; } + /** + * Materialize a localSearch `{ id, score }[]` hit list into the + * SearchResult shape consumed by the palette template. Drops hits + * whose row has fallen out of `localIndex` (stale rebuild race) and + * tags each result with its source workspace so cross-workspace + * navigation works. TASK-1365. + */ + function materializeLocalHits( + wsSlug: string, + ownerUsername: string, + hits: { id: string; score: number }[], + ): AugmentedSearchResult[] { + const out: AugmentedSearchResult[] = []; + for (const h of hits) { + const row = localIndex.findByIdOrSlug(wsSlug, h.id); + if (!row) continue; + // `Item` widens `ItemIndexRow` by adding `content` (always '' + // since the local index doesn't carry the rich-text body). + // The CommandPalette only reads title/fields/ref/etc., so an + // empty content body is fine — and matches the same widening + // pattern the collection page uses at TASK-1357. + const item: Item = { ...(row as ItemIndexRow), content: '' } as Item; + out.push({ + item, + snippet: '', + rank: h.score, + workspace: { slug: wsSlug, owner_username: ownerUsername }, + }); + } + return out; + } + + /** + * Identify the workspaces eligible for in-RAM local search: + * - Always the current workspace if it's `'ready'`. + * - When `searchAllWorkspaces` is on, every other workspace whose + * `localIndex.bootstrapStateFor` is `'ready'`. + * + * Non-ready workspaces are NOT included here — they don't have a + * MiniSearch index built yet, and triggering bootstrap from the + * search palette would be surprising. They fall back to server + * search via the alternate path. + */ + function readyWorkspaces(): { slug: string; owner_username: string }[] { + const out: { slug: string; owner_username: string }[] = []; + const current = workspaceStore.current; + if (current && localIndex.bootstrapStateFor(current.slug) === 'ready') { + out.push({ slug: current.slug, owner_username: current.owner_username ?? '' }); + } + if (!searchAllWorkspaces) return out; + for (const ws of workspaceStore.workspaces) { + if (current && ws.slug === current.slug) continue; + if (localIndex.bootstrapStateFor(ws.slug) !== 'ready') continue; + out.push({ slug: ws.slug, owner_username: ws.owner_username ?? '' }); + } + return out; + } + function doSearch() { clearTimeout(searchTimeout); - if (!query.trim()) { + const trimmed = query.trim(); + if (!trimmed) { results = []; total = 0; facets = undefined; @@ -114,39 +203,270 @@ loading = false; return; } - loading = true; - searchTimeout = setTimeout(async () => { - try { - const resp = await api.search(query, buildFilters(0)); - // Defensive: some backends / error paths can send `null` for - // an absent array. Coalesce so downstream `.length` is safe. - results = resp.results ?? []; - total = resp.total ?? 0; - facets = resp.facets; - // BUG-864: do NOT auto-arm the first result. The user must - // press an arrow key (or type a bare number + Enter) to - // trigger navigation. - selectedIdx = -1; - } catch { - results = []; - total = 0; - facets = undefined; - } finally { - loading = false; + + const parsed = parseSearchQuery(trimmed); + const currentSlug = workspaceStore.current?.slug; + // CRITICAL: don't read `localIndex.bootstrapStateFor` or + // `localSearch.epoch` for body queries — those reads register + // as reactive dependencies of the caller (`$effect`), and an + // SSE-driven epoch bump or hydration completion mid-flight + // would then re-fire doSearch and wipe an in-flight `loadMore` + // append. Body queries are server-authoritative; nothing in + // local state can change their result set. Codex round 8 P2 + // of TASK-1365. + const currentReady = parsed.body + ? true + : !!currentSlug && localIndex.bootstrapStateFor(currentSlug) === 'ready'; + const ready = parsed.body ? [] : readyWorkspaces(); + + // Server-only paths: + // - `body:` / `content:` queries — local index doesn't hold + // the rich-text body, server FTS is the only way to grep. + // - Current workspace not yet hydrated — fall through to the + // server even if other workspaces are ready, so the user + // never misses results from where they are. The + // `searchAllWorkspaces` toggle is meant to widen, never + // to replace. Codex round 2 P2. + // - No ready workspaces at all (cold session) — fall through + // to the server so the palette still works pre-bootstrap. + // All three use a 200ms debounce on the network call. + if (parsed.body || !currentReady || ready.length === 0) { + loading = true; + // Snapshot the FULL dispatch scope at request time + // (Codex rounds 4-5 P2): the response is only valid if + // every dimension that determines what we render is still + // the same. `isSameDispatch` must READ LIVE state for + // readiness — capturing `currentReady` at dispatch left + // the check stale once the index hydrated mid-request. + const snapshotQuery = trimmed; + const snapshotAllWs = searchAllWorkspaces; + const snapshotBody = parsed.body; + const snapshotFilterCollection = filterCollection; + const snapshotFilterStatus = filterStatus; + const snapshotWsSlug = currentSlug; + const isSameDispatch = () => { + if (query.trim() !== snapshotQuery) return false; + if (searchAllWorkspaces !== snapshotAllWs) return false; + if (filterCollection !== snapshotFilterCollection) return false; + if (filterStatus !== snapshotFilterStatus) return false; + if (workspaceStore.current?.slug !== snapshotWsSlug) return false; + // Body queries grep content the local index doesn't + // hold, so they're authoritative regardless of + // hydration state — only the dimensions checked above + // can invalidate them. + if (snapshotBody) return true; + // Non-body server fetches were triggered because the + // current workspace wasn't ready. If it's NOW ready, + // the local path is the authoritative source and the + // server response is stale. Read live state. + const liveCurrentReady = + !!snapshotWsSlug && + localIndex.bootstrapStateFor(snapshotWsSlug) === 'ready'; + if (liveCurrentReady) return false; + return true; + }; + searchTimeout = setTimeout(async () => { + try { + // Bare `body:` / `content:` with no following text: + // `parsed.text` is empty and we'd otherwise fall back + // to the raw query (`body:`) and ship that literal + // token to the server. Treat as a no-op until the + // user keeps typing. Codex round 9 P3 of TASK-1365. + if (parsed.body && !parsed.text) { + if (isSameDispatch()) { + results = []; + total = 0; + facets = undefined; + } + return; + } + const serverQuery = parsed.body ? parsed.text : trimmed; + if (!serverQuery.trim()) { + if (isSameDispatch()) { + results = []; + total = 0; + facets = undefined; + } + return; + } + const resp = await api.search(serverQuery, buildFilters(0)); + if (!isSameDispatch()) return; + results = resp.results ?? []; + total = resp.total ?? 0; + facets = resp.facets; + selectedIdx = -1; + } catch { + if (isSameDispatch()) { + results = []; + total = 0; + facets = undefined; + } + } finally { + if (isSameDispatch()) loading = false; + } + }, 200); + return; + } + + // Local synchronous path. For each ready workspace, run + // localSearch.search and materialize to SearchResult shape. + // Merge by score descending — ties broken by `updated_at DESC` + // for stability. + // + // Per-workspace limit: when a status filter chip is active, we + // expand the per-workspace pull so the post-fetch + // `filterStatus` filter has enough headroom to find matches + // outside the top 20 (Codex round 3 P2). The status filter + // isn't an index-aware operation on the local path — it walks + // the parsed `fields` blob after materialization — so a tight + // per-ws cap could drop valid lower-ranked matches. + const perWsLimit = filterStatus + ? LOCAL_PER_WS_LIMIT * 5 + : LOCAL_PER_WS_LIMIT; + const merged: AugmentedSearchResult[] = []; + for (const ws of ready) { + const hits = localSearch.search(ws.slug, trimmed, { + collection: filterCollection ?? undefined, + limit: perWsLimit, + }); + merged.push(...materializeLocalHits(ws.slug, ws.owner_username, hits)); + } + merged.sort((a, b) => { + if (b.rank !== a.rank) return b.rank - a.rank; + // Stable tie-break by updated_at DESC then id ASC. + const aU = a.item.updated_at ?? ''; + const bU = b.item.updated_at ?? ''; + if (aU !== bU) return aU < bU ? 1 : -1; + return a.item.id < b.item.id ? -1 : 1; + }); + + // Apply the status filter chip if active. (Collection filter is + // passed through to localSearch.search above.) + const filtered = filterStatus + ? merged.filter((r) => getFieldValue(r.item, 'status') === filterStatus) + : merged; + + // Truncate the rendered list to PAGE_SIZE * 2 so cross-workspace + // power users still see a representative top slice. CRITICAL: + // set `total = results.length` so the "Load more" affordance + // stays hidden — loadMore hits the server, which would + // inject scope-mismatched (and duplicate) rows into the local + // result set. Codex round 1 P2. Local results are all in RAM; + // if more than `PAGE_SIZE * 2` match, the right answer is a + // more specific query, not a paginated fetch. + results = filtered.slice(0, PAGE_SIZE * 2); + total = results.length; + // Facets are server-only; clear them on the local path so the + // chip row hides cleanly. + facets = undefined; + selectedIdx = -1; + loading = false; + } + + // Re-run the search whenever any tracked dependency changes: + // - `query` typed by the user (keystroke or recent-search click) + // - `searchAllWorkspaces` toggle + // - Any ready workspace's localSearch epoch (SSE-driven upserts / + // removes) — without this, an open-palette user wouldn't see + // freshly-created items even though the underlying index + // updated. PLAN-1343 / TASK-1365. + // + // EXCEPTION: body queries don't depend on local index state. They + // hit the server, which is the only source of truth for content + // text. Skip the readiness / epoch reads for body queries so a + // concurrent SSE bump (or hydration completion) can't re-fire + // doSearch from offset 0 and wipe an in-flight `loadMore` append. + // Codex round 7 P2 of TASK-1365. + // + // `doSearch` handles the empty-query case internally by clearing + // results — so the effect can fire on every transition (including + // "user backspaced to empty") without leaving stale results visible. + $effect(() => { + if (!uiStore.searchOpen) return; + void query; + void searchAllWorkspaces; + void filterCollection; + void filterStatus; + const trimmed = query.trim(); + const parsed = trimmed ? parseSearchQuery(trimmed) : null; + if (!parsed?.body) { + void localIndex.bootstrapStateFor(workspaceStore.current?.slug ?? ''); + for (const ws of workspaceStore.workspaces) { + void localSearch.epoch(ws.slug); } - }, 200); + } + doSearch(); + }); + + function loadAllWorkspacesPref(): boolean { + try { + return localStorage.getItem('pad-search-all-workspaces') === 'true'; + } catch { + return false; + } + } + + function toggleAllWorkspaces() { + searchAllWorkspaces = !searchAllWorkspaces; + try { + localStorage.setItem( + 'pad-search-all-workspaces', + searchAllWorkspaces ? 'true' : 'false', + ); + } catch { + // Storage unavailable (private mode, quota) — silently + // degrade; the toggle still works for the session. + } + // The reactive effect picks up the flip; no explicit re-run. } async function loadMore() { if (loadingMore || results.length >= total) return; + // Only the server path ever sets `total > results.length` — the + // local path pins them equal — so a `loadMore` always means + // fetching another server page. Snapshot the dispatch scope + // and bail if any of it has shifted before the response lands. + // Body queries are always server-authoritative (the local + // index doesn't carry content), so they bypass the + // live-readiness guard. Codex rounds 5-6 P2. loadingMore = true; const snapshotQuery = query; + const snapshotAllWs = searchAllWorkspaces; const snapshotCollection = filterCollection; const snapshotStatus = filterStatus; + const snapshotWsSlug = workspaceStore.current?.slug; + // Parse once and use the stripped query for the API call so + // `body:foo` → page 2 sends `foo`, not the literal `body:foo`. + // A bare `body:` / `content:` with no following text is a + // no-op — there's nothing to load a second page of. + const parsed = parseSearchQuery(query.trim()); + if (parsed.body && !parsed.text) { + loadingMore = false; + return; + } + const serverQuery = parsed.body ? parsed.text : query; try { - const resp = await api.search(query, buildFilters(results.length)); - // Discard if query or filters changed while loading - if (query !== snapshotQuery || filterCollection !== snapshotCollection || filterStatus !== snapshotStatus) return; + const resp = await api.search(serverQuery, buildFilters(results.length)); + if ( + query !== snapshotQuery || + searchAllWorkspaces !== snapshotAllWs || + filterCollection !== snapshotCollection || + filterStatus !== snapshotStatus || + workspaceStore.current?.slug !== snapshotWsSlug + ) { + return; + } + if (!parsed.body) { + // Non-body server paging: if the current workspace + // hydrated while the request was in flight, the main + // effect has likely already swapped to the local + // path and replaced `results`. Appending more server + // rows would inject scope-mismatched results. + const liveCurrentReady = + !!snapshotWsSlug && + localIndex.bootstrapStateFor(snapshotWsSlug) === 'ready'; + if (liveCurrentReady) return; + } results = [...results, ...(resp.results ?? [])]; } catch { // ignore @@ -156,18 +476,18 @@ } function applyFilter(type: 'collection' | 'status', value: string) { + // The reactive search effect re-runs on filterCollection / + // filterStatus changes, so no explicit doSearch() needed. if (type === 'collection') { filterCollection = filterCollection === value ? null : value; } else { filterStatus = filterStatus === value ? null : value; } - doSearch(); } function clearFilters() { filterCollection = null; filterStatus = null; - doSearch(); } function scrollSelectedIntoView() { @@ -230,10 +550,15 @@ } } - function selectResult(r: SearchResult) { + function selectResult(r: AugmentedSearchResult) { saveRecentSearch(query.trim()); - const ws = workspaceStore.current?.slug; - const wsUsername = workspaceStore.current?.owner_username; + // Local (cross-workspace) hits ship their source workspace on + // the result. Server hits don't — they implicitly came from the + // current workspace because `buildFilters` scopes the request. + // Resolve via fallback so both paths work. TASK-1365. + const ws = r.workspace?.slug ?? workspaceStore.current?.slug; + const wsUsername = + r.workspace?.owner_username ?? workspaceStore.current?.owner_username; const collSlug = r.item.collection_slug; if (ws && wsUsername && collSlug) { goto(`/${wsUsername}/${ws}/${collSlug}/${itemUrlId(r.item)}`); @@ -242,8 +567,9 @@ } function useRecentSearch(q: string) { + // Assignment alone re-triggers the reactive search effect; the + // explicit doSearch() call is no longer needed. query = q; - doSearch(); requestAnimationFrame(() => inputEl?.focus()); } @@ -300,13 +626,27 @@ } } - function renderResultCard(r: SearchResult, i: number): { ref: string | null; status: string | undefined; priority: string | undefined } { + function renderResultCard(r: AugmentedSearchResult, i: number): { ref: string | null; status: string | undefined; priority: string | undefined } { + void i; return { ref: formatItemRef(r.item), status: getFieldValue(r.item, 'status'), priority: getFieldValue(r.item, 'priority') }; } + + // Derived: ready workspaces other than the current one. Used to gate + // the "search all workspaces" toggle visibility — no point showing + // it when the user only has the current workspace hydrated. + let otherReadyWorkspaceCount = $derived.by(() => { + const current = workspaceStore.current?.slug; + let n = 0; + for (const ws of workspaceStore.workspaces) { + if (ws.slug === current) continue; + if (localIndex.bootstrapStateFor(ws.slug) === 'ready') n += 1; + } + return n; + }); {#if uiStore.searchOpen} @@ -334,7 +674,6 @@ @@ -361,7 +700,36 @@ - + + {#if otherReadyWorkspaceCount > 0} +
+ +
+ {/if} + + {#if facets && query.trim()}
@@ -400,6 +768,34 @@ {/if}
+ {:else if hasFilters && query.trim()} +
+
+ {#if filterCollection} + {@const coll = collectionStore.collections.find((c) => c.slug === filterCollection)} + + {/if} + {#if filterStatus} + + {/if} +
+ +
{/if} @@ -638,6 +1034,48 @@ flex-shrink: 0; } + /* "All workspaces" scope toggle (TASK-1365) */ + .scope-row { + display: flex; + align-items: center; + padding: var(--space-1) var(--space-3); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + .scope-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 2px 8px; + border-radius: var(--radius); + font-size: 0.75em; + color: var(--text-muted); + background: none; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + } + .scope-toggle:hover { + background: var(--bg-hover); + color: var(--text-secondary); + } + .scope-toggle.active { + color: var(--accent-blue); + border-color: color-mix(in srgb, var(--accent-blue) 30%, transparent); + background: color-mix(in srgb, var(--accent-blue) 10%, transparent); + } + .scope-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + border: 1px solid var(--border); + } + .scope-dot.on { + background: var(--accent-blue); + border-color: var(--accent-blue); + } + /* Filter chips */ .filters-row { display: flex;