Goal: search across sessions, session management, export and share, and a
live view of the current recording, in the page the ui-shell plan built.
Architecture: each feature is a component in ui/src over an api
method the ui-api plan already landed and tested; Rust changes are limited to
bridge-only actions (clipboard, reveal) and the live transcript state.
Tech Stack: React 19, shadcn/ui, Tailwind v4, vitest + Testing Library;
Rust objc2 for the clipboard.
Global Constraints
- This plan lands after the ui-api and ui-shell plans.
- Every feature has a test with the
FakeBridgeand adata-testidon its entry control souicheckcan drive it. - The page keeps no state the API does not have; after every mutation it re-requests.
- Look and shortcuts follow the ui-shell plan’s constraints (system font, neutral theme, 8 pt
grid,
prefers-color-scheme). - No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.
Task 1: Search with a command palette
Files:
- Create:
ui/src/components/SearchPalette.tsx,ui/src/components/SearchPalette.test.tsx - Modify:
ui/src/App.tsx(⌘Kopens it; a search icon button in the sidebar header),ui/src/components/Transcript.tsx(accepts ahighlightIndexprop and scrolls that line into view)
Interfaces:
-
Consumes:
apisearch(query,limit) returningHit[]({session, index, track, start_ms, speaker, text}),sessionsfor names. -
Produces: a shadcn
Commanddialog: typing runssearchafter 150 ms of quiet withlimit: 50, results grouped by session (name, date) with the hit text andmm:ss;Enterselects the session and scrolls the transcript toindex, highlighted for two seconds; an empty result shows “Nothing matches”;Esccloses. -
Step 1: write the failing test: typing “budget” calls
searchwith{query:"budget",limit:50}once after the debounce (fake timers); two hits render grouped under two session names;Enteron the first callsonOpenwith(session, index). -
Step 2: run
pnpm test, expect FAIL “cannot find module ./SearchPalette”. -
Step 3: implement, including the
highlightIndexscroll inTranscript. -
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build. -
Step 5: commit:
git add ui assets/settings.html && git commit -m "ui: search across sessions".
Task 2: Session management
Files:
- Create:
ui/src/components/SessionHeader.tsx,ui/src/components/SessionHeader.test.tsx; shadcndialog,alert-dialog,textarea,popover - Modify:
ui/src/pages/Sessions.tsx(rendersSessionHeaderaboveTranscriptfor the selected session and reloadssessionsafter every header action),ui/src/components/SessionList.tsx(pinned group at the top, tag chips, context menu)
Interfaces:
-
Consumes:
apisession.update(session, optionalname,notes,pinned,add_tag,remove_tag; tags are never sent as a whole array, so a tag added by the CLI or MCP while the page was open survives),session.delete(session),sessions(withtags,pinned), bridgereveal. -
Produces: the transcript header shows the name as an inline-editable field (click or
Enterto edit,Esccancels), a pin toggle, tag chips with an add popover, a notes textarea that saves on blur, and a…menu with Reveal in Finder and Delete; Delete opens anAlertDialognaming the session and its duration, and callssession.deleteonly on confirm; a live session’s header shows “Recording” and disables everything but Reveal. -
Step 1: write the failing tests: editing the name and pressing
Entercallssession.update {session, name}; adding a tag callssession.update {session, add_tag}and the chip’s × calls{session, remove_tag}; the header re-renders from thesessionsreply, not from local state; Delete then Confirm callssession.delete; Delete then Cancel calls nothing; alivesummary renders the disabled state. -
Step 2: run
pnpm test, expect FAIL “cannot find module ./SessionHeader”. -
Step 3: implement;
SessionListrenders pinned sessions first under a “Pinned” label and tag chips under the name. -
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build. -
Step 5: commit:
git add ui assets/settings.html && git commit -m "ui: rename, pin, tag, note and delete sessions".
Task 3: Export and share
Files:
- Create:
ui/src/components/ExportMenu.tsx,ui/src/components/ExportMenu.test.tsx - Modify:
ui/src/components/SessionHeader.tsx(rendersExportMenuin its toolbar for every non-live session; the ui-shell plan’s Copy Markdown button inTranscriptstays as it is, and the menu does not repeat it) - Modify:
src/settings.rs(bridge-only methodsave {session, format}viaNSSavePanelwritingapiexport’s output, reply{result: {path: string|null}}, null when cancelled;clipboard.writealready exists from the ui-shell plan’s Task 3) - Modify:
docs/using/commands.md(theexportsection mentions the window’s menu)
Interfaces:
-
Consumes:
apiexport(session,formatinmarkdown|text|json|srt|vtt|assistant). -
Produces: an Export button with a dropdown: Copy for an assistant, Save as… → submenu of Markdown / Plain text / JSON / SRT / VTT; Copy actions call
exportthenclipboard.writeand show a shadcn toast “Copied”; Save callssaveand toasts the path; a rejectedexport,clipboard.writeorsaveshows a destructive toast “Export failed:” with the bridge’s message, and the menu stays usable. -
Step 1: write the failing tests: “Copy for an assistant” calls
export {format:"assistant"}thenclipboard.writewith the returned text; “Save as… → SRT” callssave {session, format:"srt"}with the selected session’s id; a{path: null}reply shows no toast; the “Copied” toast appears; anexportthat rejects with “no such session” shows “Export failed: no such session”. -
Step 2: run
pnpm test, expect FAIL “cannot find module ./ExportMenu”. -
Step 3: implement both sides;
uicheckgains a step that opens the menu, clicks Copy for an assistant and asserts the bridge receivedexport {format: "assistant"}thenclipboard.write. -
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build;node .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add ui assets/settings.html src/settings.rs src/bin/uicheck.rs docs/using/commands.md && git commit -m "ui: export and copy for an assistant".
Task 4: The live view, as live as the transcript is
Files:
- Create:
ui/src/components/LiveTranscript.tsx,ui/src/components/LiveTranscript.test.tsx - Modify:
ui/src/pages/Sessions.tsx(renders<LiveTranscript key={session}>instead ofTranscriptwhile the selected session’s summary isliveortranscribing; thekeyremounts it on every selection change, so the cursor restarts at 0 and the previous session’s timer is cleared on unmount, and its polls go throughuseLatest, so a delayed reply for the previous session is dropped);LivePaneand its test are untouched
Interfaces:
-
Consumes: event
phase(live.id,live.elapsed_s, levels,status_line),apitranscript(session,since,verbatim) whose reply is{session, state, next, lines}withstateinlive|transcribing|pending|doneandnextthe append-order cursor, as the MCP plan defines it and the ui-api plan’s Task 1 restates; a missingraw.jsonlduringliveisstate: "live"with no lines, not an error. -
Produces: selecting the live session shows the live card full-width with a transcript area beneath it; while
stateisliveortranscribingthe page pollstranscriptevery 2 s with the lastnextassince, appends new lines with a fade-in, and shows “Listening…” (live) or “Transcribing…” (transcribing) when there are no lines yet; whenstatebecomesdonethe view swaps to the normal transcript. Todayraw.jsonlis written after capture ends, so duringlivethe area shows only “Listening…” and the meters; the polling makes the page ready for the live-writer plan without changes. -
Step 1: write the failing test: with a
FakeBridgewhosetranscriptreturns{state:"transcribing", next:1, lines:[…one]}then{state:"transcribing", next:2, lines:[…one]}then{state:"done", next:2, lines:[]}, fake timers advance 2 s twice andLiveTranscriptshows two lines then callsonDone, andSessionsswaps toTranscript;sinceis0,1,2in that order; a first reply{state:"live", next:0, lines:[]}renders “Listening…”; selecting live session B after A had reached cursor 5 issuestranscript {session: B, since: 0}, and A’s reply released afterwards adds nothing to B’s view. -
Step 2: run
pnpm test, expect FAIL on the new assertions. -
Step 3: implement.
-
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build. -
Step 5: commit:
git add ui assets/settings.html && git commit -m "ui: live view polls the transcript cursor".
Tasks 5–6 were implemented in the September 6 follow-up. See the completion audit for observed checks and the remaining independent review gate. The steps below retain the original plan.
Task 5: Filters and the sidebar at scale
Files:
- Create:
ui/src/components/SidebarFilters.tsx,ui/src/components/SidebarFilters.test.tsx - Modify:
ui/src/components/SessionList.tsx(virtualised list, no library: every session row is a fixed 56 px, every month header a fixed 28 px, so the offset of any row is arithmetic; the name is one line, truncated with an ellipsis (truncate), and the tags render as a single non-wrapping line that truncates with an ellipsis and a “+N” chip, never a second line; the list renders a top spacer, the rows intersecting the viewport plus 20 of overscan, and a bottom spacer, recomputed onscrolland onResizeObserver)
Interfaces:
-
Consumes:
sessions(tags,started_at,name,duration_s). -
Produces: a filter row above the list: a text filter over name and tags, a tag chip picker (chips from every tag seen), and a date group header per month; the list stays under 16 ms per frame at 2,000 sessions (measured in the test with a fake list and
performance.now, asserting fewer than 300 rendered rows). -
Step 1: write the failing tests: 2,000 fake summaries with a 600 px viewport render fewer than 60 row elements; setting
scrollTopto the bottom and firingscrollrenders the last session and the top spacer’s height equals the arithmetic offset; a session with six tags renders one row of 56 px, and so does one with a 300-character name in a 200 px wide list; typing “standup” leaves only the matching names; picking the tag “1:1” leaves only sessions with it; month headers appear once per month. -
Step 2: run
pnpm test, expect FAIL “cannot find module ./SidebarFilters”. -
Step 3: implement.
-
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build. -
Step 5: commit:
git add ui assets/settings.html && git commit -m "ui: filters and a sidebar that scales".
Task 6: Onboarding and health in the page
Files:
- Create:
ui/src/pages/Welcome.tsx,ui/src/pages/Welcome.test.tsx - Modify:
ui/src/App.tsx(show Welcome whendoctorreports any FAIL or there are no sessions),src/api.rs(methoddoctorreturning the checks fromdoctor::run, session-tools plan Task 5),docs/developing/api.md
Interfaces:
-
Consumes:
apidoctor→[{name, ok, detail}],sessions. -
Produces: a welcome page listing the checks as a checklist with the detail under each failed one and the fix from
docs/using/troubleshooting.mdas a sentence (models: run./fetch-models.sh; permissions: open System Settings), and a “Start recording” call to action that callsrecord.start. No link out: the docs site is not deployed (docs-site/astro.config.ts,docs/developing/docs.md), so the fix text is carried inline and kept in step withdocs/using/troubleshooting.mdby a test that asserts each check name’s fix sentence appears in that file. -
Step 1: write the failing tests: with two FAIL checks the page renders both details and no call to action; with all ok and no sessions it renders the call to action, and clicking it calls
record.start; every fix sentence the page can show is a substring ofdocs/using/troubleshooting.md(read in the test withfs). -
Step 2: run
pnpm test, expect FAIL “cannot find module ./pages/Welcome”. -
Step 3: implement, including the
doctorapi method and its test insrc/api.rs. -
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build;node .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add ui assets/settings.html src/api.rs docs/developing/api.md && git commit -m "ui: welcome page from doctor".