~bigbes/lethe

ref: a1e67ca703ed1c77d8629f2bdbb57668f85c98c2 lethe/docs/tasks/lethe-web-ui-palette-savedsearch.md -rw-r--r-- 41.3 KiB
a1e67ca7 — Eugene Blikh collector: add Claude Code parser 24 days ago

#lethe-web-ui-palette-savedsearch

Status: Designed Module: sourcecraft.dev/bigbes/lethe Branch: master Worktree: none Parent RFC: Personal AI Assistant Log Aggregator (2026-04-25) Sibling tasks:

  • lethe-server.md (#1, ✓ Verified) — owner of /api/v1, auth, error model
  • lethe-web-ui-foundation.md (#4, ✓ Reviewed) — palette skeleton, Settings stub, Session adapter
  • lethe-web-ui-aggregates.md (#5, ✓ Reviewed) — /projects endpoint and useProjects hook
  • lethe-search-and-opencode.md (#3, deferred) — owner of /api/v1/search; this task's saved-searches dead-end at the /search stub until #3 ships
  • lethe-web-ui-search.md (#7, blocked on #3) — search route that consumes saved-search "execute"
  • lethe-web-ui-settings-display.md (#8, deferred) — fills the display slot of the Settings sidebar this task lays down
  • lethe-oidc-stub.md (#10, ✓ Reviewed) — provides the dev OP for browser-smoke walks of this task

#Design

#Purpose

Extend the ⌘K palette beyond jump-shortcuts to surface PROJECT and SESSION items plus user-defined saved searches. Add a saved-searches storage layer (one table, four endpoints, owner-scoped). Replace the /settings stub with a sectioned layout that hosts saved-search CRUD UI today and leaves a populated slot for #8's Display section.

In scope: backend saved_searches table + domain package + four endpoints; palette item-type expansion (jump | project | session | saved); recent-50 prefetch from existing /projects and /sessions; sectioned /settings layout with the Saved searches section live and a placeholder for Display; one carry-over fix on the Session adapter to unblock palette session-navigation without re-introducing the composite-id-in-URL bug.

Out of scope: /api/v1/search and the search results UI (#3 + #7); Settings → Display (#8); palette server-side fuzzy filtering; saved-search filter fields beyond query; saved-search clone/duplicate UI.

#Chosen approach

Backend — saved-searches resource (additive)

  • Migration 0002_saved_searches.up.sql: one table saved_searches(owner, name, query, created_at, updated_at) with composite PK (owner, name) and a covering index (owner, updated_at DESC). No FTS, no triggers, no foreign keys — the table is a standalone island.
  • Domain package internal/domain/savedsearch/ with repository.go + repository_test.go + handler.go + handler_test.go, mirroring the project/ and session/ shapes (steward injection, Init, Mount, apierror.Render for errors).
  • Four routes mounted by Server.Init inside the existing /api/v1 group:
    • GET /api/v1/saved-searches — list owner's rows, ordered by updated_at DESC.
    • POST /api/v1/saved-searches — create { name, query } → 201 with row body; 409 CONFLICT on duplicate name; 400 VALIDATION on empty/over-length name or empty query.
    • PUT /api/v1/saved-searches/{name} — patch { name?, query? }; 404 NOT_FOUND if absent; 409 CONFLICT on rename collision; 400 VALIDATION on empty fields.
    • DELETE /api/v1/saved-searches/{name} — 204 No Content; 404 if absent.
  • Owner derives exclusively from the auth context. No ?owner=* admin sentinel — saved searches are intrinsically per-user; cross-owner reads have no useful semantic.

Frontend — palette extension (no /projects or /sessions query-shape changes)

  • On open=true, palette fires three TanStack queries with staleTime: 30s:
    • /api/v1/projects?limit=50 (recent projects by last-active)
    • /api/v1/sessions?limit=50 (recent sessions by started-at)
    • /api/v1/saved-searches (all of the user's, by updated-at)
  • The JumpItem interface widens to PaletteItem = JumpItem | ProjectItem | SessionItem | SavedItem. All four share { kind, label, hint?, ... }; per-kind extra fields carry the navigation payload.
  • Render groups in fixed order: filtered jumps → matching projects → matching sessions → matching saved searches. The inline "search '$q'" suggestion (already present) still appears at the top when query is non-empty.
  • Filtering is pure client-side case-insensitive String.includes over each item's label.
  • Per-kind activate:
    • jumpnavigate({ to: item.path }) (unchanged).
    • projectnavigate({ to: '/project/$', params: { _splat: cwd } }).
    • sessionnavigate({ to: '/session/$tool/$host/$id', params: { tool, host, id: sessionId } }) using the bare sessionId (see carry-over below).
    • savednavigate({ to: '/search', search: { q: query } }). The /search route is a stub today; the link is a no-op-with-a-tag until #7. PC1.

Frontend — sectioned /settings layout

  • web/src/routes/settings.tsx becomes a single-route, two-column shell: left rail with section titles, right panel rendering the active section. State is local useState<'saved-searches' | 'display'>('saved-searches') — no sub-routes (IV5).
  • The display entry renders disabled with a "in #8" muted tag so the slot is visible but unselectable. #8 fills the panel and removes the muted tag.
  • New folder web/src/features/settings/:
    • SectionRail.tsx — vertical nav primitive (~25 lines, plain divs + classnames).
    • SavedSearchesSection.tsx — list table + inline "new saved search" form (name + query) + per-row edit/delete buttons; mutations through TanStack Query, invalidation on success.
  • API client extensions in web/src/api/:
    • useSavedSearches() — query.
    • useCreateSavedSearch(), useUpdateSavedSearch(), useDeleteSavedSearch() — mutations with onSuccess: invalidate('saved-searches').

Carry-over folded in (one tiny scope item)

web/src/api/adapters.ts — the Session interface gains sessionId: string (bare id) alongside the existing composite id (kept for compat with code that already reads it). The single existing call site in web/src/routes/index.tsx that builds /session/$tool/$host/$id switches params.id from s.id (composite) to s.sessionId (bare). The palette session-item activate uses the same bare field. Why fold in: this task constructs the same /session/$tool/$host/$id URL the existing call site does; without the fix, the palette would either reproduce the carry-over bug or invent a parallel workaround (s.id.split('/')[2]). Exposing the bare field once and using it twice closes the foundation Conclusion's "Composite-id-in-URL" carry-over for free.

#Backwards-compatibility check

All changes additive:

  • Migration 0002_saved_searches.up.sql is forward-only; 0002_saved_searches.down.sql provided for symmetry. No schema modification of existing tables.
  • New endpoints under new path prefix /api/v1/saved-searches; no existing consumers.
  • Session.sessionId is a new adapter field; nothing removed. Existing readers of Session.id keep working.
  • Settings route replaces a stub (<EmptyState glyph="∅" copy="coming in a later task" />); no deployed user is affected.
  • Palette item-union widens; no external consumer of those types beyond the file itself.

Greenfield modulo the one additive adapter field.

#TDD: yes (scoped)

  • Yes for: internal/domain/savedsearch/repository_test.go (4 CRUD methods + duplicate-name + missing-name + ordering edge cases); internal/domain/savedsearch/handler_test.go (auth gating, all 4 routes, RFC 7807 error shapes, JSON DTO round-trip); TS adapters.test.ts extension covering the new sessionId field and the saved-search DTO round-trip; vitest for useSavedSearches cache-invalidation on mutation.
  • No for: SectionRail and SavedSearchesSection chrome (visual / interactive — typecheck + a manual smoke walk via the OIDC dev stub from #10 covers feel); palette filter UX (single String.includes, dominated by visual feel).

#Invariants

  • IV1 — saved_searches is a standalone island: no FTS triggers, no foreign keys, no cross-table writes; only the saved_searches table is touched by this task's repository.
  • IV2 — All /api/v1/saved-searches/* routes ignore ?owner= and read owner exclusively from the auth context.
  • IV3 — (owner, name) is unique; duplicate names per owner produce 409 CONFLICT, never silent overwrite.
  • IV4 — Palette adds no new query parameters to /projects or /sessions; the recent-50 lists come from those endpoints unchanged.
  • IV5 — /settings remains a single route in this task; no sub-routes (/settings/saved-searches, /settings/display) are introduced.
  • IV6 — Session.id (composite) and Session.sessionId (bare) coexist on the adapter; the only existing call site changed by this task is web/src/routes/index.tsx (its navigate switches params.id to s.sessionId). New call sites added by this task (the palette session-item activate) read s.sessionId directly from the start.
  • IV7 — Saved-search name is validated server-side: non-empty, ≤ 64 chars, no /. The 64-char cap (UK1) and the slash exclusion are enforced in the create + update handlers and surface as 400 VALIDATION.

#Principles

  • PC1 — Saved-search activate navigates to /search?q=… even though /search is a stub today; do not implement any search functionality in this task. Why: scope hygiene — task #7 owns the search route. How to apply: if the urge arises during execute to "make saved-search actually run a query", stop and add it to a #7 follow-up note instead.
  • PC2 — Palette item ordering is kind-grouped (jumps → projects → sessions → saved), not relevance-ranked. The inline "search '$q'" suggestion sits above all groups when the query is non-empty; it is the only item that floats above the kind order. Why: stable order beats fuzzy ranking at personal-aggregator scale; users learn positions over a few opens. How to apply: when adding a new palette item kind in the future, append it to the group order, don't interleave by score; reserve the above-groups slot for the search-suggestion only.

#Assumptions

  • AS1 — Top-50 recent projects + top-50 recent sessions cover the overwhelming majority of palette opens for personal-scale data (O(dozens) of projects, O(hundreds) of recent sessions). Why this matters: if AS1 fails, palette feels "incomplete" and option (b)/(c) live-server-filter from Question 1 becomes the right next move. Verify in Conclusion: report observed prefetch-coverage from a manual smoke walk.
  • AS2 — TanStack Query mutation + invalidation patterns from useSessions/useProjects carry over without surprises to the four new saved-search hooks.

#Unknowns

  • UK1 — Reasonable name length cap for saved searches. Locked to 64 chars in IV7; chosen as a defensive default for "label" UX. Revise during plan only if there's a UI evidence point.
  • UK2 — Whether the "save current search" affordance lives on the /search route (#7) or is creatable only from /settings. Initial answer: only from /settings for this task — the search route doesn't exist yet to host the affordance. #7's design will decide whether to add a ★ save button.

#Plan

Approach: four phases — PH1 lands the saved-searches resource end-to-end on the backend (migration + domain package + steward wiring) so the frontend has a real endpoint to call; PH2 ships the adapter additions (Session.sessionId carry-over + SavedSearch DTO) the rest of the frontend phases depend on; PH3 builds the API hooks + sectioned /settings layout; PH4 widens the palette. PH1 ⊥ PH2 (different stacks, disjoint paths); PH3 + PH4 fan out from PH2 and run on disjoint frontend paths.

#PH1 — Backend: saved_searches table + domain package + mount

  • 1.1 internal/platform/database/migrations/0002_saved_searches.up.sql (create) — single statement, IV1:
    CREATE TABLE saved_searches (
        owner      TEXT NOT NULL,
        name       TEXT NOT NULL,
        query      TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        PRIMARY KEY (owner, name)
    ) WITHOUT ROWID;
    CREATE INDEX idx_saved_searches_owner_updated
        ON saved_searches (owner, updated_at DESC);
    
    No FTS, no triggers, no FK (IV1). WITHOUT ROWID matches the access pattern (composite PK is the read path).
  • 1.2 internal/platform/database/migrations/0002_saved_searches.down.sql (create) — DROP INDEX … ; DROP TABLE saved_searches;. Order reversed from up.
  • 1.3 internal/domain/savedsearch/repository.go (create) — package savedsearch. Mirrors internal/domain/session/repository.go shape.
    • type SavedSearch struct { Owner string; Name string; Query string; CreatedAt int64; UpdatedAt int64 } with matching db and json tags (owner is db only — never marshaled to clients per IV2).
    • type Repository struct { Database *database.Database \inject:""` }`
    • func (r *Repository) Init(_ context.Context) error { return nil }
    • func (r *Repository) List(ctx context.Context, owner string) ([]SavedSearch, error)WHERE owner = ? ORDER BY updated_at DESC. Returns non-nil zero-length slice on empty (matches project.Repository.List).
    • func (r *Repository) Create(ctx context.Context, s SavedSearch) errorINSERT INTO saved_searches(owner,name,query,created_at,updated_at) VALUES(?,?,?,?,?). On sqlite3.ErrConstraintPrimaryKey (or driver's UNIQUE/PK error) → culpa.WithCode(..., "CONFLICT") with public detail "saved search with that name already exists" (IV3).
    • func (r *Repository) Update(ctx context.Context, owner, oldName string, newName *string, newQuery *string, now int64) (SavedSearch, error) — single UPDATE … WHERE owner=? AND name=? with conditional SET clauses; RowsAffected == 0NOT_FOUND; PK collision on rename → CONFLICT. Returns the post-update row via a follow-up SELECT in the same context (read-after-write).
    • func (r *Repository) Delete(ctx context.Context, owner, name string) errorDELETE WHERE owner=? AND name=?; RowsAffected == 0NOT_FOUND.
    • Respects: IV1 (single-table writes), IV3 (composite PK enforces 409).
  • 1.4 internal/domain/savedsearch/handler.go (create) — mirrors internal/domain/session/handler.go:33-47.
    • type Handler struct { Repo *Repository \inject:""` }`
    • Init, Mount(r chi.Router) registering: r.Get("/saved-searches", h.List), r.Post("/saved-searches", h.Create), r.Put("/saved-searches/{name}", h.Update), r.Delete("/saved-searches/{name}", h.Delete).
    • func (h *Handler) ownerOf(r *http.Request) string { return auth.MustIdentity(r.Context()).User } — derives owner from auth identity only; ?owner= is read but ignored on read paths and rejected as INVALID on write paths (IV2). Single helper, one source.
    • validateName(name string) error — non-empty, len ≤ 64, no / → 400 VALIDATION with public detail (IV7, UK1). One source for create + update.
    • validateQuery(query string) error — non-empty → 400 VALIDATION.
    • List writes { saved_searches: []SavedSearch } (omits Owner field via json:"-" on SavedSearch.Owner).
    • Create decodes { name, query }, validates, calls Repo.Create with now := time.Now().Unix() injected for both timestamps, returns 201 with the new row (re-read or constructed locally).
    • Update reads {name} from URL, decodes { name?, query? } (both optional but at least one required → 400 if both nil), validates non-nil fields, calls Repo.Update, returns 200 with the updated row.
    • Delete reads {name} from URL, calls Repo.Delete, returns 204 No Content.
    • All errors flow through apierror.Render (RFC 7807).
    • Respects: IV2, IV3, IV7. No clampLimit/clampOffset (no pagination — list is small and owner-scoped; avoids a third copy of the helpers per GPC8).
  • 1.5 internal/domain/savedsearch/repository_test.go (create) — TDD targets, in-memory SQLite via the existing test harness:
    • empty DB → List returns [] non-nil
    • Create then List → row roundtrips (created_at == updated_at == injected now)
    • Create twice with same (owner, name) → second returns CONFLICT-coded error
    • Create with same name but different owner → both rows present (IV3)
    • Update non-existent (owner, name)NOT_FOUND
    • Update rename onto an existing (owner, name)CONFLICT
    • Update query only (newName=nil) → name unchanged, query updated, updated_at advances
    • Delete non-existent → NOT_FOUND; existing → no error and absent on subsequent List
    • List order: rows returned by updated_at DESC (insert three with staggered now, assert order).
  • 1.6 internal/domain/savedsearch/handler_test.go (create) — TDD targets, mirrors internal/domain/session/handler_test.go setup:
    • GET /saved-searches unauthenticated → 401 (auth middleware) — covered indirectly by reusing the test fixture; a single sanity case.
    • GET /saved-searches authenticated → 200 with { saved_searches: [] }; Owner field absent in JSON.
    • GET /saved-searches?owner=alice (any caller) → 200, identical body to no-param call (IV2: owner param ignored on read).
    • POST /saved-searches {name:"",query:"x"} → 400 VALIDATION
    • POST /saved-searches {name:"a/b",query:"x"} → 400 VALIDATION (slash exclusion — IV7)
    • POST /saved-searches {name:"<65 chars>",query:"x"} → 400 VALIDATION (UK1 cap)
    • POST /saved-searches {name:"x",query:""} → 400 VALIDATION
    • POST /saved-searches valid → 201 with row
    • POST /saved-searches duplicate → 409 CONFLICT with problem+json
    • PUT /saved-searches/missing → 404
    • PUT /saved-searches/x {name:"y"} when y exists → 409
    • DELETE /saved-searches/x → 204; subsequent DELETE → 404
  • 1.7 internal/server/server.go:51-67 (modify) — add SavedSearches *savedsearch.Handler \inject:""`` field; in Init (line 100-106) add s.SavedSearches.Mount(r) inside the /api/v1 Route block. Import alongside existing project/stats imports.
  • 1.8 cmd/lethe/main.go:96-132 (modify) — add savedsearchRepo := &savedsearch.Repository{} and savedsearchHnd := &savedsearch.Handler{} to the registered slice and mgr.AddComponent block, mirroring projectRepo/projectHnd placement (between statsHnd and serverSvc). Add the import.
  • Commit: savedsearch: add /api/v1/saved-searches CRUD with 0002 migration

#PH2 — Frontend adapter: Session.sessionId carry-over + SavedSearch DTO

  • 2.1 web/src/api/adapters.ts:21-34 (modify) — Session interface gains sessionId: string (bare id) alongside the existing composite id. Both populated by adaptSession (adapters.ts:36-51):
    • id: \${d.tool}/${d.host}/${d.session_id}`` — unchanged
    • sessionId: d.session_id — new (IV6).
  • 2.2 web/src/api/adapters.ts (modify, append after the Stats block) — add saved-search types:
    • interface SavedSearchDTO { name: string; query: string; created_at: number; updated_at: number }
    • interface SavedSearch { name: string; query: string; createdAt: string; updatedAt: string } (ISO)
    • function adaptSavedSearch(d: SavedSearchDTO): SavedSearch — ISO conversion via new Date(unix * 1000).toISOString().
  • 2.3 web/src/routes/index.tsx:48 (modify) — change params: { tool: s.tool, host: s.host, id: s.id } to params: { tool: s.tool, host: s.host, id: s.sessionId } (IV6, closes the foundation Conclusion's "Composite-id-in-URL" carry-over).
  • 2.4 web/src/routes/project.$.tsx:33 (modify) — same swap as 2.3 at the project-detail row-click site. Deviation note: design said "single existing call site"; in the time since that doc was approved a second call site shipped in #5 (lethe-web-ui-aggregates). Fixing both is the spirit of the carry-over fold-in (per design rationale: "without the fix, the palette would either reproduce the carry-over bug or invent a parallel workaround"); fixing only one would leave a worse split than today. Documented under Deviations to flag for review.
  • 2.5 web/src/api/adapters.test.ts (modify) — TDD. Add cases:
    • adaptSession populates sessionId === d.session_id (bare id)
    • adaptSession keeps id as the composite (regression — verify both coexist, IV6)
    • adaptSavedSearch round-trip: created_at=1700000000createdAt === '2023-11-14T22:13:20.000Z'; same for updatedAt
    • adaptSavedSearch passes name and query through unchanged
  • Commit: web: adapter — add Session.sessionId, SavedSearch DTO; fix composite-id call sites

#PH3 — Frontend: saved-search hooks + sectioned /settings

  • 3.1 web/src/features/settings/useSavedSearches.ts (create) — TanStack Query hooks. Mirrors web/src/features/projects/useProjects.ts:1-45 shape.
    • function useSavedSearches(): UseQueryResult<SavedSearch[]> — keyed ['saved-searches'], calls apiFetch<{ saved_searches: SavedSearchDTO[] }>('/api/v1/saved-searches'), maps through adaptSavedSearch. staleTime: 30_000 to match palette prefetch.
    • function useCreateSavedSearch(): UseMutationResult<SavedSearch, Error, { name: string; query: string }>POST /api/v1/saved-searches, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }).
    • function useUpdateSavedSearch(): UseMutationResult<SavedSearch, Error, { oldName: string; name?: string; query?: string }>PUT /api/v1/saved-searches/${encodeURIComponent(oldName)}, same invalidation.
    • function useDeleteSavedSearch(): UseMutationResult<void, Error, { name: string }>DELETE /api/v1/saved-searches/${encodeURIComponent(name)}, same invalidation.
    • Respects: AS2 (TanStack mutation+invalidation pattern carries over from useSessions/useProjects).
  • 3.2 web/src/features/settings/useSavedSearches.test.ts (create) — TDD: cache-invalidation behavior. One representative case: render useSavedSearches and useCreateSavedSearch under a shared QueryClient; mock apiFetch to return [] initially, [{name:'a',query:'q',…}] after the POST; assert that after mutate({name:'a',query:'q'}) resolves, the query result re-fetches and contains the new row. Uses @testing-library/react renderHook + waitFor.
  • 3.3 web/src/features/settings/SectionRail.tsx (create) — vertical nav primitive (~25 lines, plain divs).
    • function SectionRail<K extends string>(props: { sections: { key: K; label: string; disabled?: boolean; tag?: string }[]; active: K; onSelect: (k: K) => void }): React.JSX.Element
    • Renders one row per section; disabled rows are not clickable and render the tag (e.g. "in #8") as a muted span; the active row gets the --accent-soft background + 2px accent left border (matches SessionsTable cursor styling — GPC8 reuse).
  • 3.4 web/src/features/settings/SavedSearchesSection.tsx (create) — list table + inline new-form + per-row edit/delete.
    • function SavedSearchesSection(): React.JSX.Element — uses the four hooks from 3.1.
    • States: list view (existing rows, "+ new" row with two inputs name and query + save button); per-row inline edit toggle that swaps the row to two inputs + save/cancel.
    • Mutation success → React Query invalidates and re-renders; inline form clears.
    • Server error (409 CONFLICT, 400 VALIDATION) surfaces as a small inline message under the row (read error.detail from APIError).
    • Empty state: <EmptyState glyph="∅" copy="no saved searches yet" />.
  • 3.5 web/src/routes/settings.tsx (replace, currently settings.tsx:1-21) — sectioned shell.
    • const SECTIONS = [{ key: 'saved-searches', label: 'Saved searches' }, { key: 'display', label: 'Display', disabled: true, tag: 'in #8' }] as const
    • useState<'saved-searches' | 'display'>('saved-searches') — local state; no sub-routes (IV5).
    • Two-column layout: <SectionRail> left, <SavedSearchesSection> right when active is saved-searches.
    • SubBar shows <Tag kind="neutral">settings</Tag> (carry from existing).
  • 3.6 web/src/styles/settings.css (create) — .settings-shell (two-column grid: 220px sidebar + 1fr panel), .section-rail, .section-row, .section-row.active, .section-row.disabled, .saved-searches-table, .saved-search-form rules. Imported at the top of routes/settings.tsx (matches the per-route import pattern set by home.css/projects.css/stats.css).
  • Respects: IV5 (single route, no sub-routes), AS2.
  • Commit: web: sectioned /settings with saved-searches CRUD

#PH4 — Frontend: Palette extension (jump | project | session | saved)

  • 4.1 web/src/shell/Palette.tsx:9-22 (modify) — replace the JumpItem interface and JUMP_ITEMS constant block with the wider union:
    • type PaletteItem = JumpItem | ProjectItem | SessionItem | SavedItem
    • All four share { kind; label; hint?: string }; per-kind extras carry navigation payload:
      • interface JumpItem { kind: 'jump'; label; hint; path: string } (unchanged)
      • interface ProjectItem { kind: 'project'; label: string /* cwd */; hint?: string; cwd: string }
      • interface SessionItem { kind: 'session'; label: string /* ${tool} · ${host} · summary?*/; hint?: string; tool: string; host: string; sessionId: string }uses sessionId (IF1), never composite (IV6)
      • interface SavedItem { kind: 'saved'; label: string /* name */; hint?: string; query: string }
  • 4.2 web/src/shell/Palette.tsx:24-47 (modify, inside Palette) — when open === true, fire three TanStack queries with staleTime: 30_000:
    • useProjects({ since: 'all' }) (existing hook from web/src/features/projects/useProjects.ts — no new query parameter on the endpoint, IV4)
    • useSessions({}) from features/home/useSessions.ts — no cwd filter, no new param (IV4); cap to first 50 client-side via .slice(0, 50) (AS1).
    • useSavedSearches() from PH3 (IF2).
    • enabled: open on each so closing the palette stops refetching.
  • 4.3 web/src/shell/Palette.tsx:49-57 (modify) — replace the single-list filter with kind-grouped filtering. Filter each list by String.prototype.toLowerCase().includes(q.toLowerCase()) over label. Build filtered = [...filteredJumps, ...filteredProjects.slice(0,50), ...filteredSessions, ...filteredSaved] — fixed group order (PC2). When q !== '' and any group has zero matches, the existing "search '$q'" suggestion still floats above all groups (PC2).
  • 4.4 web/src/shell/Palette.tsx:59-70 (modify) — fire(idx) switch on the activated item's kind:
    • jumpnavigate({ to: item.path }) (unchanged)
    • projectnavigate({ to: '/project/$', params: { _splat: item.cwd } })
    • sessionnavigate({ to: '/session/$tool/$host/$id', params: { tool: item.tool, host: item.host, id: item.sessionId } }) — bare id (IV6)
    • savednavigate({ to: '/search', search: { q: item.query } }) (PC1 — /search is the stub from foundation, no execution wired)
  • 4.5 web/src/shell/Palette.tsx:104-135 (modify) — render groups in fixed order with a thin section header per group only when the group is non-empty (<div className="palette-group-head muted mono">projects</div> etc.). The existing palette-row markup is per-item; the kind span already carries the kind label (visual cue stays).
  • 4.6 web/src/styles/palette.css (modify) — add .palette-group-head rule (font-size: 11px, uppercase, muted color, padding). One small additive block.
  • Respects: IV4, IV6, PC1, PC2, AS1.
  • Commit: web: palette items — projects, sessions, saved searches

#Test strategy

  • PH1 TDD: every bullet in 1.5 / 1.6 is a failing test first. repository_test.go covers IV3 (composite PK) and IV1 (table-isolation by virtue of single-table writes). handler_test.go covers IV2 (owner ignored on read, derived from auth on write), IV7 + UK1 (validation cases).
  • PH2 TDD: cases in 2.5 cover IV6 (both fields coexist) and SavedSearch DTO round-trip.
  • PH3 TDD: only the cache-invalidation case in 3.2 (per Design's TDD scope: cache-invalidation tests on, layout chrome off).
  • PH4 no TDD: filter UX is String.includes, dominated by visual feel; manual smoke walk covers it.

#Order & dependencies

  • PH1 ⊥ PH2 (Go vs TS, disjoint paths).
  • PH3 depends on PH2 (adaptSavedSearch, SavedSearch types).
  • PH4 depends on PH2 (Session.sessionId) and PH3 (useSavedSearches).
  • PH3 + PH4 also runtime-depend on PH1's endpoints — typecheck-independent, but smoke-testing them needs PH1 in place.
  • Sequence: PH1 → PH2 → PH3 → PH4. PH1 and PH2 may run in parallel since their paths are disjoint.

#Risks / rollback

  • RK1 — PH1's Update rename SQL relies on the SQLite driver surfacing PK-collision as a constraint error code that culpa.WithCode(..., "CONFLICT") translates correctly. Mitigation: the repository_test.go Update-rename-collision case in 1.5 fails fast if the error mapping is off; existing session.Repository.Get already maps SQLite errors via the same pattern.
  • RK2 — PH4's three-query prefetch on every palette open could refetch faster than staleTime if React StrictMode double-mounts (dev only). Mitigation: staleTime: 30_000 plus enabled: open means open=false disables the queries entirely; tested by walking the palette open/close cycle.
  • RK3 — Migration rollback: 0002 down drops the table outright. Acceptable because the design declares saved-searches a "standalone island" (IV1) and there is no data we can't reconstruct. If a user has populated saved-searches in prod, down deletes them; that's the documented contract for a standalone resource.

#Interfaces

  • IF1 — interface Session { … sessionId: string } — bare session id field added to the adapter, consumed by palette session-item activate.
  • IF2 — useSavedSearches(): UseQueryResult<SavedSearch[]> (and three mutation hooks) exported from web/src/features/settings/useSavedSearches.ts — produced by PH3, consumed by PH4 for palette saved-search prefetch.
  • IF3 — GET /api/v1/saved-searches returns { saved_searches: SavedSearchDTO[] } (owner derived from auth, IV2) — produced by PH1, consumed by PH3's hooks.

#Interface graph

  • PH1 -> IF3 @ internal/domain/savedsearch/, internal/platform/database/migrations/, internal/server/server.go, cmd/lethe/main.go
  • PH2 -> IF1 @ web/src/api/adapters.ts, web/src/api/adapters.test.ts, web/src/routes/index.tsx, web/src/routes/project.$.tsx
  • PH3 IF3 -> IF2 @ web/src/features/settings/, web/src/routes/settings.tsx, web/src/styles/settings.css
  • PH4 IF1, IF2 -> @ web/src/shell/Palette.tsx, web/src/styles/palette.css

Wave check: PH1 and PH2 share no paths and consume no shared IF — they form Wave 1. PH3 forms Wave 2 (consumes IF3). PH4 forms Wave 3 (consumes IF1, IF2).

#Backwards-compat check

Restating Design's "all changes additive" with concrete phases:

  • PH1 migration 0002_saved_searches.up.sql is forward-only on a new table; no modification of any existing table. down.sql provided for symmetry; data loss on rollback is the documented contract for an island resource (IV1).
  • PH1 endpoints live under a new path prefix /api/v1/saved-searches; no existing consumer reads them.
  • PH2 adds Session.sessionId (additive); existing readers of Session.id continue to work (IV6 — both coexist). Two call-site swaps (routes/index.tsx, routes/project.$.tsx) replace a known buggy URL shape with the correct one — strictly an improvement; no rollback regression.
  • PH3 replaces the /settings stub (<EmptyState glyph="∅" copy="coming in a later task" />); no deployed user is affected.
  • PH4 widens the palette item union; no external consumer of those types beyond Palette.tsx itself.

No removed / renamed JSON fields anywhere. No schema modifications to existing tables. Greenfield modulo the additive Session.sessionId adapter field.

#Conclusion

Outcome: shipped — dcafcb2..HEAD (HEAD = 8d80e87); 9 commits across PH1–PH4, two review-fix commits, three doc commits.

Invariants:

  • IV1 — 0002_saved_searches.up.sql has no FTS / triggers / FK; savedsearch.Repository only writes to saved_searches (grep on the diff is empty).
  • IV2 — ?owner= is silently ignored on read (TestHandler_List_OwnerParamIgnored) and rejected as INVALID on every write path (TestHandler_WritePaths_OwnerParamRejected covers POST/PUT/DELETE). Reviewer found the DELETE gap (Important #2); fix in 8d80e87 plus consistency-pass extension to all three write paths.
  • IV3 — composite PK enforced by SQLite; Create maps PK collision to CONFLICT via isSQLiteConstraint; Update rename collision tested at TestHandler_Update_RenameConflict.
  • IV4 — palette consumes useProjects/useSessions unchanged; no new query parameters added (grep on Palette.tsx).
  • IV5 — /settings remains a single route; find web/src/routes -name 'settings*' → only routes/settings.tsx.
  • IV6 — Session.id (composite) and Session.sessionId (bare) coexist; URL-path uses (routes/index.tsx:48, routes/project.$.tsx:33, Palette.tsx:137) all pass s.sessionId; React key in SessionsTable.tsx:61 keeps composite intentionally.
  • IV7 + UK1 — validateName enforces non-empty, ≤64 chars, no / on Create + Update; six handler tests cover the cases.

#Assumptions check

  • AS1 — held — sessions list is capped client-side at .slice(0, 50) (Palette.tsx:95); projects list is capped server-side by the existing 50-item default limit (functionally equivalent — reviewer noted but did not flag).
  • AS2 — held — all four saved-search hooks call invalidateQueries({ queryKey: ['saved-searches'] }) on success; cache-invalidation test useSavedSearches.test.ts verifies the round-trip.

#Unknowns outcome

  • UK1 — resolved — validateName enforces ≤64 chars; chosen as a defensive default for "label" UX. No UI evidence point surfaced during plan or execute that argued for a different cap.
  • UK2 — out of scope — ★ save button on /search (#7) is explicitly that task's design call. This task creates saved searches only from /settings per design.

#Review findings

  • Critical: none.
  • Important (2 — both resolved):
    1. Palette prefetch hooks lacked enabled: open and staleTime: 30_000 (plan 4.2 explicitly required both as RK2's mitigation). Without them, default TanStack Query v5 staleTime: 0 plus refetch-on-focus/reconnect/mount caused refetch storms on every parent re-render even when the palette was closed. Fixed in 6971b2d by extending useProjects/useSessions/useSavedSearches with an optional { enabled?, staleTime? } second parameter; existing call sites unchanged (parameter is optional).
    2. Delete handler did not reject ?owner=, breaking the consistency of the IV2 write-path rejection contract that Create and Update enforced. No test guarded any of the three write paths' rejections. Fixed in 8d80e87: added the same guard to Delete, plus a consistency-pass extension that adds TestHandler_WritePaths_OwnerParamRejected covering all three verbs.

#Deviations from plan

  • PH2 — second Session.id call site also fixed (web/src/routes/project.$.tsx:33): Plan bullet 2.4 already flagged this. Design said "single existing call site"; a second call site shipped in #5 (lethe-web-ui-aggregates) between design approval and execute. User explicitly approved option 1 (fix both) before dispatch. Both call sites now pass s.sessionId to params.id; React key in SessionsTable.tsx:61 keeps s.id (composite) intentionally — IV6's "both coexist" exists for exactly this asymmetry.
  • PH3 — apiFetchVoid helper added in web/src/api/client.ts (outside PH3's declared Owns set): backend DELETE /api/v1/saved-searches/{name} returns 204 No Content; the existing apiFetch<T> calls resp.json() unconditionally and would throw SyntaxError on an empty body. The dispatcher's pre-flight authorized this exact helper so the foundation invariant ("all API calls flow through apiFetch/its sibling — no raw fetch outside client.ts") stays intact. Single new exported function; mirrors apiFetch except it skips JSON decode and returns void.
  • PH3 — internal/server/web/dist/index.html regen included in the commit (outside PH3's declared Owns set): Vite stamps content-hash bundle filenames; npm run build rewrote dist/index.html to point at the new asset hashes. #5's verify recorded the same artifact as a follow-up commit (f6611d7); catching it in-phase here is the corrected pattern.
  • Verify — register savedsearch.Repository/savedsearch.Handler in the e2e test's local steward graph (cmd/lethe/main_e2e_test.go): PH1 added a *savedsearch.Handler inject:"" field on Server and updated cmd/lethe/main.go accordingly, but missed the sibling main_e2e_test.go which constructs its own steward graph that mirrors main.go. TestEndToEnd_MultiUserIsolation panicked at Inject with failed to find dependency: target=server.Server targetField=SavedSearches dependencyType=*savedsearch.Handler. Verify-driven fix-up commit 964d802 adds the import and two MustServiceAsset lines, restoring the e2e test. Filed as a deviation because PH1's plan named only cmd/lethe/main.go for steward registration and the consistency sweep should have grepped for MustServiceAsset siblings before commit.

#Future work

  • Extract usePaletteData() if the prefetch pattern grows a third caller. Today three call sites pass { enabled, staleTime } to three independent hooks; the duplication is below the rule-of-three threshold. If a fourth surface (e.g. a quick-switcher) wants the same prefetch shape, fold it into one hook.
  • Add a ★ save button on /search (UK2) — owned by #7, not this task.

#Verified by

  • Browser smoke deferred — see Verify → Notes → "Browser smoke deferred".

#Verify

Result: passed

Positive:

  • CK1 — go test ./internal/domain/savedsearch/... -race -count=1 → ok (22 tests)
  • CK2 — go test ./internal/... -count=1 → ok (all packages)
  • CK2b — go test ./cmd/lethe/... -count=1 → ok after fix-up commit 964d802 (initial run panicked: see Deviations)
  • CK3 — go build ./cmd/lethe/ → 32 MB binary
  • CK4 — cd web && npm test -- --run → 50 passed across 4 files
  • CK5 — cd web && npm run build → 376 modules, 491 KB JS / 150 KB gzip; dist/index.html matches tracked artifact

Negative:

  • CK6 (slash in name) — TestHandler_Create_SlashInNameReturns400 covers POST {name:"a/b"} → 400 VALIDATION
  • CK7 (duplicate) — TestHandler_Create_DuplicateReturns409 covers second POST same (owner, name) → 409 CONFLICT
  • CK8 (missing) — TestHandler_Delete_Sequence covers second DELETE → 404 NOT_FOUND

Invariants / assumptions:

  • CK9 (IV1) — grep -iE 'TRIGGER|FOREIGN KEY|FTS|REFERENCES' 0002_saved_searches.up.sql → empty
  • CK10 (IV2) — owner derives from auth.MustIdentity via ownerOf (handler.go:46-48); ?owner= on writes is rejected as INVALID, on reads is silently ignored — TestHandler_List_OwnerParamIgnored asserts the read path
  • CK11 (IV3) — PRIMARY KEY (owner, name) in 0002 migration
  • CK12 (IV4) — Palette.tsx consumes useProjects/useSessions unchanged; no new query params constructed in either hook; AS1 cap is client-side .slice(0, 50)
  • CK13 (IV5) — find web/src/routes -name 'settings*' → only routes/settings.tsx; no sub-route files
  • CK14 (IV6) — Session.id (composite, ${tool}/${host}/${session_id}) and Session.sessionId (bare) both populated in adaptSession; SessionsTable.tsx:61 keeps s.id as React key (unique cross-tool/host); routes/index.tsx:48, routes/project.$.tsx:33, Palette.tsx:137 all use s.sessionId for params.id
  • CK15 (IV7+UK1) — validateName (handler.go:54-72): name == "" → 400, len(name) > 64 → 400, strings.Contains(name, "/") → 400. Called on Create + Update.
  • CK16 (PC1) — saved-search palette activate at Palette.tsx:141navigate({ to: '/search', search: { q: item.query } }); /search is the foundation stub, no execution wired
  • CK17 (AS1) — Palette.tsx:95 caps sessions list with .slice(0, 50) before mapping to SessionItem

Interfaces:

  • CK18 (IF1) — Session.sessionId: string declared at adapters.ts:23, populated at :40, consumed by Palette.tsx:32, 98, 101, 137; bare id never replaced by composite anywhere on the URL path
  • CK19 (IF2) — useSavedSearches exported from web/src/features/settings/useSavedSearches.ts:9, consumed at Palette.tsx:5, 78 and SavedSearchesSection.tsx
  • CK20 (IF3) — GET /api/v1/saved-searches returns { saved_searches: [SavedSearch] } (top-level key); Owner field omitted via json:"-" (IV2). TestHandler_List_OwnerFieldAbsentInJSON asserts the JSON shape; frontend useSavedSearches.ts:8, 16 consumes the data.saved_searches key.

Notes:

  • Browser smoke deferred/settings saved-search CRUD and the widened palette were not exercised in a real browser. tsc + vitest + the production bundle are clean and the backend is verified by handler tests, but a full Chrome walk requires the running server + an authenticated session. Task #10 (lethe-oidc-stub, ✓ Reviewed) ships the cmd/oidc-stub binary that unblocks this, but standing it up and driving the UI is out of reach in autonomous mode without user input. Recommended walk: (a) start lethe with the OIDC dev stub config, (b) sign in, (c) ⌘K and confirm projects/sessions/saved-search items appear in fixed group order, (d) /settings and create / rename / delete a saved search, (e) press a saved-search row in the palette and confirm it lands on /search?q=… (stub).
  • Verify-driven fix-up commit 964d802 — PH1 missed cmd/lethe/main_e2e_test.go's steward graph; recorded under Conclusion → Deviations from plan.