Topic navigation surfaces (ADR-0012 follow-up)
Status: Approved 2026-05-06
Branch: feature/topic-graph
Goal
Make typed topics (β.1.7) navigable. Today they’re rendered as inline highlights and chips, but those highlights link nowhere. After this slice the user can click any topic anywhere → see all memories that mention it, browse a topic index, search by topic, and read the Universe edge mix at a glance. Web AND mobile, no Pro gate.
Items
1. /topic/[id] detail page
Web — apps/web/app/(app)/topic/[id]/page.tsx. Server component.
Query: topics (id, label, kind) + memories joined via memory_topics (id, summary, transcript snippet, recorded_at, recording_id, recordings(…)).
Layout:
- Header: kind chip (colored per
KIND_STYLES) + topic label + “{N} memories” - Body: same row layout as Today (audio button + summary + date), each row →
/memory/[id] - Back link → wherever user came from (use
/topicsas fallback)
Mobile — apps/mobile/app/topic/[id].tsx. Same data shape. SectionList grouped by date bucket (matches Today pattern).
Both surfaces resolve topic by UUID (id), not slug. Labels can collide and contain punctuation.
2. /topics index
Web — apps/web/app/(app)/topics/page.tsx. Server component.
Data: SQL view topic_with_counts (added in a small migration) returning (id, user_id, label, kind, memory_count) with security_invoker=true so RLS owns scoping.
Layout:
- Filter pill row at top: All / Person / Place / Project / Theme / Event (kind filter, query param)
- List sorted by
memory_count desc, label asc - Each row: kind chip + label + count, →
/topic/[id] - Empty state copy if 0 topics
Mobile — apps/mobile/app/topics.tsx. Same data via topic_with_counts view.
Nav — Add “Topics” link to web (app)/layout.tsx nav (between Today and Universe). Mobile gets it as a Link from Today’s header (alongside Settings).
3. Today feed entity badges
Web — extend lib/search.ts to also fetch memory_topics(topics(id, label, kind)). Render up to 3 chips per row in today/page.tsx, prefer person/place/project over theme/event. Chips link to /topic/[id] (with e.stopPropagation() so they don’t trigger the row’s navigation).
Mobile — extend app/index.tsx load() to pull memory_topics(topics(id, label, kind)). Render same top-3 chips in MemoryRowItem. Tap → /topic/[id].
4. Universe header edge breakdown
apps/web/app/(app)/universe/page.tsx — split edges into topic vs semantic counts. Render "42 connections (18 topic + 24 semantic)". ~5 lines.
5. topic: search operator
Web — extend lib/search.ts to parse topic:Foo and topic:"Foo Bar" tokens out of the query string. Resolve each via topics.canonical_key lower(label) match for the current user. Filter the candidate memory id set through memory_topics. Free-text remainder still goes through FTS + semantic. Results = intersection.
Mobile — app/index.tsx load() — same parser, applies the topic filter via .in('id', memoryIds) after resolving topic ids.
Bonus: /today?topic=<id> URL param, populated by /topic/[id] “see all in feed” link.
Data layer
One migration: supabase/migrations/20260507000000_topic_with_counts_view.sql. Adds the topic_with_counts view used by item 2.
Order
- Spec + view migration
- Item 4 (warmup, web only, 5 lines)
- Item 1 web → 1 mobile
- Item 2 web (+ nav) → 2 mobile
- Item 3 web → 3 mobile
- Item 5 web → 5 mobile
- Wire web inline highlights to
/topic/[id] - Type check (
pnpm typecheckper app), smoke test
Out of scope
- User-driven topic merge/split UX (deferred per ADR-0012)
- Per-kind icons (kind colors only)
- Universe filter by topic id (Universe already has its own pill)
- Topic edit/delete
Risk
memory_topics(topics(...))join is many-to-one in supabase-js types but typed as array; same pattern already used on memory detail — copy the cast.- Mobile RLS:
memory_topicsselect policy is via parent memory; same path the join already uses. - View
security_invoker=trueis required soauth.uid()resolves from the calling user, not the view owner. Mirror what 20260506000004 did forpipeline_stuck_jobs.