---
path: /search
surface: customer
domain: discovery
auth: signed-in
source: apps/oshun/web/src/app/search/page.tsx
status: walked
last_walked:
  '2026-06-27 targeted search walk — direct URL filter hydration, live
  /v1/search guarded-speech seed, filter-driven empty state, Reset all, Clear to
  no-query, saved/recent search memory, real BFF result handoffs, preview,
  assistant handoff, evidence sidebar, library save/resume, fetch failure Retry,
  and open-result analytics verified in Playwright. Evidence:
  apps/oshun/web/e2e/search-saved-and-recent.spec.ts,
  apps/oshun/web/e2e/search-workspace-layout.spec.ts,
  apps/oshun/web/e2e/search-result-analytics.spec.ts,
  apps/oshun/web/e2e/sophia-grounded-answer-page.spec.ts'
---

# Search

## Purpose

A dedicated search results surface that complements `/explore`. Issues queries
against the BFF `/v1/search` endpoint, groups results by domain, supports
relevance / kind / score / saved / match-scope filters, persists recent and
saved searches in localStorage, and surfaces an evidence sidebar for trust
signals.

## Entry points

- **Shell nav: Explore → Search** — breadcrumbs `Explore → Search`
  (`active="explore"`); reached from typing in the shell search bar, or via
  direct URL with `?q=`
- **Explore page** — issuing a search in `/explore` can navigate here (verify
  internal cross-link) or stay in the embedded search feed; both surfaces hit
  the same `/v1/search` BFF
- **Direct URL / bookmark** — yes; `?q=<query>` and other filter params hydrate
  state via `useBrowserSearchParams()`:
  - `q` or `query` — initial query
  - `domain` — initial `SearchDomain`
  - `kind` or `kinds` — initial `kindFilters[]`
  - `sort`, `score`, `saved`, `scope` — initial sort/score/saved/match-scope
- **Cross-domain "Save search" / recents** — stored under
  `oshun-search-saved-v1` and `oshun-search-recent-v1`

## Layout regions

`page.tsx` mounts `ShellLayout` with `active="explore"` and breadcrumbs
`Explore → Search`, renders `<h1 className="sr-only">Search · OSHUN</h1>`, then
`<SearchResultsView />`.

Inside `SearchResultsView`:

- **Shell persistent context strip** — `ShellPersistentContextStrip` at top
- **Search input lane** — large `<input ref={inputRef}>` with submit button,
  saved/recent-query menu, suggestion chips (`getSearchSuggestions(domain)`)
- **Filter toolbar** — `WorkspaceToolbar` with:
  - **Domain segments** (`SEARCH_DOMAIN_FILTERS`)
  - **Sort segments** — Relevance / Recent / Domain
  - **Advanced filters panel** (collapsible, id `oshun-search-advanced-filters`)
    holding kind multi-select chips, score filter (All / Good+ / Strong+ /
    Excellent), saved filter (All / Saved only / Unsaved only), match-scope
    filter (Anywhere / Title hits / Summary hits)
- **Results list / domain groups** — grouped by domain when sortMode allows;
  each result renders one of seven templates
  (`default | concept | passage | source | claim | notebook | program | ritual`)
- **Skeleton list** — `SKELETON_COUNT = 5` skeletons during loading
- **Evidence sidebar** — `EvidenceSidebar` shown on laptop+ breakpoints
  (`splitLayout = isLaptopUp(viewport)`)
- **Empty / no-query states** — distinct screens for "type a search" vs "no
  matches"

## States

- [x] **No query** — `submittedQuery.trim() === ''`; results cleared,
      `searched === false`; suggestion + recent-search affordances visible
- [ ] **Loading (skeleton)** — `loading === true` after submitting a query; five
      skeleton rows render with the shared count-up animation
- [x] **Results populated** — `results.length > 0`; grouped by
      `WebNavigableDomainId` when grouping is active
- [ ] **Empty results** —
      `loading === false && searched === true && results.length === 0`; animated
      empty state copy
- [x] **Filter-driven empty** — kind/score/saved/match-scope filter active but
      backend returned results (filtered client-side); empty branch shows the
      no-results copy and the toolbar exposes `Reset all`
- [x] **Fetch failure** — non-OK response sets `data-search-error`, clears
      result rows, and exposes same-query `Try again`
- [x] **Saved/recent search restore** — selecting a stored query rehydrates
      every filter via `applySearchWorkspaceState`
- [x] **Result preview** — `previewedResultKey` controls in-place result preview
      affordance (verify exact UI)
- [x] **Library save toggle** — saving a result calls
      `toggleOshunWebLibraryItem(buildLibraryItemFromSearchResult(...))` and
      fires `trackLibraryItemSaved` / `trackLibraryItemUnsaved`
- [ ] **Reduced motion** — `disableAnimation` removes input focus pulses, result
      entrance staggers, and count-up animations
- [ ] **Standalone PWA / Offline** — _no view-specific branch; relies on shell
      networking._

## Interactions

### Search input

- [x] **Query input** (text input, `inputRef`)
  - Function: updates `query`; submit (Enter or button) sets `submittedQuery`
    and triggers `/v1/search` fetch
  - URL: `applySearchWorkspaceState` rewrites `?q=` and other params via
    `window.history` (no Next router navigation)
- [x] **Submit / search button** — submits current `query`
- [x] **Clear (×)** — clears query, `submittedQuery`, results, and returns to
      the no-query state
- [ ] **Suggestion chip** — empty-state suggestions render as static text chips;
      decide whether they should become clickable query refinements
- [x] **Recent search entry** (per `SEARCH_RECENT_STORAGE_KEY`)
  - Function:
    `applySearchWorkspaceState(stored, { recordRecent: false, openFilters: true })`;
    rehydrates the full saved workspace
  - Storage: `localStorage['oshun-search-recent-v1']`, max 6 entries
- [x] **Saved search entry** (per `SEARCH_SAVED_STORAGE_KEY`)
  - Storage: `localStorage['oshun-search-saved-v1']`, max 8 entries

### Filter toolbar

- [x] **Domain segment rail** — `SEARCH_DOMAIN_FILTERS`; sets `domainFilter`;
      re-issues `/v1/search` with the new `domain` parameter
- [x] **Sort segment rail** — Relevance / Recent / Domain
- [x] **Advanced filters toggle** — opens/closes the
      `#oshun-search-advanced-filters` panel; default-open when any non-default
      advanced filter is present in the URL
- [x] **Kind multi-select chips** — toggle `kindFilters[]`; URL writes as
      `?kind=` (one) or `?kinds=` (csv)
- [x] **Score filter** — All / Good+ / Strong+ / Excellent (client-side filter
      against `result.score`)
- [x] **Saved filter** — All / Saved only / Unsaved only (uses
      `useOshunWebLibraryStore`)
- [x] **Match-scope filter** — Anywhere / Title hits / Summary hits (highlights
      the search term using `getSearchResultPresentation`)

### Result row

- [x] **Title link** — opens `buildSearchResultLaunchPath(result)`
- [x] **Library save toggle** — visible when
      `canSaveSearchResultToLibrary(result)`
- [x] **Result detail templates** — vary by `kind`: passage / source / claim /
      notebook / program / ritual / concept / default; each surfaces
      domain-specific metadata (citation count, confidence score, progress, …)
- [x] **Preview** — opens an in-place preview (`previewedResultKey`) when the
      result template supports it
- [x] **Open result** — fires `trackSearchResultOpened` analytics

### Evidence sidebar (laptop+)

- [x] **Evidence list** — driven by `deriveGroundedEvidenceStatus(results)`;
      shows current trust posture (`high | medium | low | unknown`) for the
      visible result set

### Assistant entry

- [x] **Dispatch assistant from a result** — `dispatchOshunAssistantOpen(...)`
      opens the in-app assistant scoped to the result context

## Data & contracts

- **Reads**:
  - BFF `GET /v1/search?q=&domain=` — minimal payload here:
    `{ results?: SearchResultItem[] }` (see local `SearchResultItem` shape with
    domain-specific optional fields)
  - `useOshunWebLibraryStore()` — saved-item snapshot for the saved filter
  - `useBrowserSearchParams()` — URL hydration
  - `localStorage['oshun-search-recent-v1']` — recent searches
  - `localStorage['oshun-search-saved-v1']` — saved searches
- **Writes**:
  - `toggleOshunWebLibraryItem(...)` — client store
  - `localStorage` writes for recents/saved via `writeStoredSearchEntries`
- **Realtime**: _None._
- **Caching**: `cancelled` flag on each fetch prevents stale writes; debouncing
  is not present in this file (submit-driven), unlike `/explore`
- **Auth/role check**: `Authorization: Bearer ${resolveBffAuthToken() ?? ''}`.
  `resolveBffAuthToken()` (`lib/bff-auth.ts`) prefers the real session token
  (`tryGetApiAuthToken()`) and only returns the `LOCAL_DEV_FALLBACK_TOKEN` when
  `NODE_ENV !== 'production'`, returning `null` in prod — not a hard-coded
  literal
- **Telemetry**: `trackSearchResultOpened`, `trackLibraryItemSaved`,
  `trackLibraryItemUnsaved`

## Cross-references

- Shell: [`shell/01-app-shell.md`](../../shell/01-app-shell.md)
- Sibling routes:
  - [`explore.md`](./explore.md) — parent in breadcrumb; shares `/v1/search`
  - [`home.md`](./home.md), [`library.md`](./library.md),
    [`activity.md`](./activity.md), [`messages.md`](./messages.md),
    [`switcher.md`](./switcher.md)
- Component sources:
  - `apps/oshun/web/src/components/search/SearchResultsView.tsx`
  - `apps/oshun/web/src/components/search/search-config.ts`
  - `apps/oshun/web/src/components/search/SearchScanCard.tsx`
  - `apps/oshun/web/src/design-system/components/EvidenceSidebar.tsx`
- Feature spec: [`V1/features.md`](../../../V1/features.md)

## E2E coverage

- `apps/oshun/web/e2e/search-saved-and-recent.spec.ts` — recent/saved memory,
  direct URL filter hydration, live BFF guarded-speech seed, filter-driven empty
  state, Reset all, Clear to no-query, result opens, save/resume, fetch failure
  Retry, and Browser Back restoration
- `apps/oshun/web/e2e/search-workspace-layout.spec.ts` — split/stacked preview
  lane and assistant handoff from selected result context
- `apps/oshun/web/e2e/search-result-analytics.spec.ts` — dedicated search-page
  `search_result_opened` payloads
- `apps/oshun/web/e2e/sophia-grounded-answer-page.spec.ts` — live BFF Nisaba
  search result preview, grounded evidence sidebar, and support notes

## Open questions / known gaps

- [x] Document the search analytics funnel — `trackSearchResultOpened` fires on
      result open/full handoff; preview and library save use separate events
- [ ] Confirm whether `/search` should also surface partial-failure outage
      banners (it issues the same BFF endpoint as `/explore`, which already
      handles `partialFailure`)
- [ ] Decide whether empty-state suggestion chips should become interactive; the
      current `EmptyState` renders them as static spans
- [x] Dev `Authorization: Bearer dev....` fallback is already prod-safe —
      `resolveBffAuthToken()` returns `null` in production (the dev literal is
      gated behind `NODE_ENV !== 'production'`)
- [ ] Map the full set of result template kinds to the BFF contract — current
      file enumerates seven templates but only checks the minimal
      `SearchResultItem` fields on parse
