feat(web): CommandPalette uses localSearch (TASK-1365) (#511)

* 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.
This commit is contained in:
xarmian
2026-05-12 00:46:55 -04:00
committed by GitHub
parent aea4c3435b
commit 06c5e5cd2d
@@ -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<SearchResult[]>([]);
let results = $state<AugmentedSearchResult[]>([]);
let total = $state(0);
let facets = $state<SearchFacets | undefined>(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;
});
</script>
{#if uiStore.searchOpen}
@@ -334,7 +674,6 @@
<input
bind:this={inputEl}
bind:value={query}
oninput={doSearch}
placeholder="Search items, collections, docs..."
class="search-input"
/>
@@ -361,7 +700,36 @@
</button>
</div>
<!-- Filter chips -->
<!--
"All workspaces" toggle (TASK-1365). Only surfaced when the
user has more than one ready workspace — if you only have
the current workspace hydrated, the toggle would be a no-op
and just adds chrome.
-->
{#if otherReadyWorkspaceCount > 0}
<div class="scope-row">
<button
class="scope-toggle"
class:active={searchAllWorkspaces}
onclick={toggleAllWorkspaces}
title="When on, search every workspace that's already loaded this session"
>
<span class="scope-dot" class:on={searchAllWorkspaces}></span>
<span>All workspaces ({otherReadyWorkspaceCount + 1} ready)</span>
</button>
</div>
{/if}
<!--
Filter chips. Surface in three cases:
1. Server search returned facets (full chip vocabulary).
2. Local path with an active filter — show the active
chip(s) alone so the user can see + clear them.
Without this branch, switching from server → local
with a filter chip selected would hide the chip but
keep filtering, leaving the user no clear way to
remove it. Codex round 2 P2 of TASK-1365.
-->
{#if facets && query.trim()}
<div class="filters-row">
<div class="filter-chips">
@@ -400,6 +768,34 @@
<button class="clear-filters" onclick={clearFilters}>Clear filters</button>
{/if}
</div>
{:else if hasFilters && query.trim()}
<div class="filters-row">
<div class="filter-chips">
{#if filterCollection}
{@const coll = collectionStore.collections.find((c) => c.slug === filterCollection)}
<button
class="filter-chip active"
onclick={() => applyFilter('collection', filterCollection!)}
>
<span class="chip-icon">{coll?.icon || '📦'}</span>
<span class="chip-label">{coll?.name || filterCollection}</span>
</button>
{/if}
{#if filterStatus}
<button
class="filter-chip status-chip active"
onclick={() => applyFilter('status', filterStatus!)}
>
<span
class="chip-dot"
style="background: {statusColor(filterStatus)};"
></span>
<span class="chip-label">{filterStatus.replace(/_/g, ' ')}</span>
</button>
{/if}
</div>
<button class="clear-filters" onclick={clearFilters}>Clear filters</button>
</div>
{/if}
<!-- Result count -->
@@ -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;