Goal: replace the native session browser and the settings page with one
React page that the window hosts, at feature parity with today, looking like
a first-party Mac app (ADR 0017).
Architecture: ui/ becomes a React 19 + shadcn/ui + Tailwind v4 app built
by Vite into the single inlined assets/settings.html (the committed bundle
name stays, the CI drift check depends on it). The bridge is request/response
({id, method, params} → window.ambient.reply) plus events
(window.ambient.event), routed in Rust through api::call. Each task leaves
the app working: settings first, then sessions and transcript, then the live
pane, then the host swap.
Tech Stack: React 19, shadcn/ui (components copied into
ui/src/components/ui), Tailwind v4 via @tailwindcss/vite, lucide-react,
vitest + @testing-library/react + jsdom, pnpm; Rust objc2 host.
Global Constraints
- The bundle is one HTML file with everything inlined (
vite-plugin-singlefile,assetsInlineLimit: Infinity, targetsafari18); nothing is fetched at runtime. Build output staysassets/settings.html. - Every dependency is pinned to the exact version
pnpm addresolves at task time and recorded inui/package.json; the lockfile is committed; CI runspnpm install --frozen-lockfile. - Look: system font stack (
-apple-system, system-ui), shadcnneutralbase colour,--radius0.5rem, HIG spacing (8 pt grid), light/dark fromprefers-color-scheme(thedarkclass is set from amatchMedialistener, no toggle), sidebar uses thesidebartokens. No custom display type. - Tests run with
pnpm test(vitest run) under node with jsdom; every component task adds tests that render the component with a fake bridge. - The Rust bridge holds the state; the page keeps only what is on screen and re-requests after every
event. - This plan lands after the ui-api plan.
- No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.uicheck(src/bin/uicheck.rs) is a binary, not a test, and is updated in the task that changes what it drives.
Task 1: Toolchain and bridge v2
Files:
- Modify:
ui/package.json,ui/vite.config.ts,ui/tsconfig.json,ui/index.html - Create:
ui/components.json,ui/src/styles/globals.css,ui/src/lib/utils.ts,ui/src/lib/bridge.ts,ui/src/lib/bridge.test.ts,ui/src/main.tsx,ui/src/App.tsx,ui/vitest.config.ts,ui/src/test/setup.ts - Delete:
ui/src/main.ts,ui/src/bridge.ts,ui/src/settings.ts(their behaviour returns in Task 2) - Modify:
src/settings.rs:81-101(Bridge::did_receiveparses{id, method, params}and answers),src/settings.rs:269-319(pushbecomesevent("config", …))
Interfaces:
-
Produces (page):
bridge.call<T>(method: string, params?: object): Promise<T>posting{"id": <n>, "method", "params"}towindow.webkit.messageHandlers.ambient, resolving when Rust callswindow.ambient.reply(id, {result} | {error: {kind, message}});bridge.on(name, handler)forwindow.ambient.event(name, payload); aFakeBridgeinui/src/test/fake-bridge.tsthat answers from a map for tests and can hold a reply until the test releases it. Stale replies: every screen that loads data for a selection keeps a generation counter, bumps it on each new selection, and drops a reply whose generation is not current (auseLatest(call)hook inui/src/lib/latest.tsthat all data-loading components use). -
Produces (Rust):
Bridgeroutes every message toapi::callwith the realPaths, replying viaevaluateJavaScript("window.ambient.reply(<id>, <json>)");Bridge::event(&self, name, payload), which queues the payload while the page has not yet calledinitand flushes the queue oninit; the page callsinitonce on mount and receives{route: "sessions"|"settings"}, the route Rust holds in aCellthatselect_settingssets (so a cold launch through Settings… lands on Settings whether the event or the page comes first); the oldPatchpath is deleted in Task 2, so this task keepshandlefor messages withoutid. -
Step 1: bootstrap:
pnpm add react react-dom lucide-react class-variance-authority clsx tailwind-merge tailwindcss @tailwindcss/vite tw-animate-cssandpnpm add -D @vitejs/plugin-react @types/react @types/react-dom vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event, thenpnpm dlx shadcn@latest initwith base colourneutral, CSS variables on,@/alias toui/src;pnpm dlx shadcn@latest add button. Keepvite-plugin-singlefile,oxlint,oxfmt. Scripts:check(tsc),lint,fmt,test(vitest run),build(unchanged copy step). -
Step 2: write the failing test
bridge.test.ts:call("ping")posts one message withid: 1and resolves whenwindow.ambient.reply(1, {result: {}})is invoked; an{error}reply rejects withmessage;on("config", h)is called bywindow.ambient.event("config", {...});useLatest: two calls issued A then B whose replies arrive B then A leave B’s result in place (A’s reply is dropped);Appcallsiniton mount, renders the route it returns, and switches route on anavigate {"page"}event (window.ambient.event("navigate", {page: "settings"})after mount shows Settings). On the Rust side, a test on the pure queue: aneventbeforeinitis held and delivered after it, in order. Runpnpm test, expect FAIL “cannot find module ./bridge”. -
Step 3: implement
bridge.ts,App.tsxrendering a shadcnButtonlabelled “Ambient”, and the Rust side.pnpm test,pnpm check,pnpm lint,pnpm buildall pass;cargo run --bin uicheckstill loads the page (it may report the old ids as missing; note that in the report, Task 2 restores them). -
Step 4: run
scripts/check, expectCHECK OK; quotepnpm test’s summary line. -
Step 5: commit:
git add ui assets/settings.html src/settings.rs && git commit -m "ui: react, shadcn, and a request/response bridge".
Task 2: Settings in React, at parity
Files:
- Create:
ui/src/pages/Settings.tsx,ui/src/pages/Settings.test.tsx, shadcn components via the CLI:switch,select,slider,input,badge,separator,label - Modify:
ui/src/App.tsx,src/settings.rs(deletePatch,Assign,handle,push; keepBridge::stashedand the scope logic behind it as a bridge-only methodcapture.scope {everything: bool}with today’s semantics: switching to Everything stashes the app list and clears it in config, switching back restores the stash; the page callsconfig.get,config.set,capture.scope,roster.*,speakers.*,devices, andpick_app/pick_dir, which stay native panels answered through the same reply path),src/bin/uicheck.rs(drive the new controls bydata-testid)
Interfaces:
-
Consumes:
apimethodsconfig.get(whoselatest_sessionnames the session the unnamed-speaker control asksspeakers.unnamedandspeakers.nameabout; the control is hidden when it is null),config.set,devices,roster.list|add|remove,speakers.unnamed,speakers.name(ui-api plan Task 6) and three bridge-only methods:capture.scope(above),pick_appandpick_dir(nativeNSOpenPanel,src/settings.rs:227-265), which reply{result: {chosen: string|null}}. -
Produces: every control today’s page has (
ui/index.html,ui/src/main.ts: capture scope, app list with add/remove, mic select, diarize switch, threshold slider, sessions dir with choose, ask-before-recording, retention select, roster chips, unnamed-speaker assignment), each with adata-testid;config.setwhile recording is refused with the API’s message shown inline. -
Step 1: write the failing tests: with a
FakeBridgereturning a config, the page renders the mic select with the devices, toggling diarize callsconfig.set {key:"diarize", value:"false"}, choosing Everything callscapture.scope {everything: true}and choosing Selected apps again calls{everything: false}(the Rust side’s existing stash test keeps covering the restore), adding a person callsroster.add, and aconfig.seterror is shown as text containing “recording”. -
Step 2: run
pnpm test, expect FAIL “cannot find module ./pages/Settings”. -
Step 3: implement; update
uicheckto click the new test ids and to print every message the bridge receives, as it does today. Runcargo run --bin uicheckand quote its last three lines. -
Step 4: run
scripts/check, expectCHECK OK;pnpm test,pnpm check,pnpm lint,pnpm build. -
Step 5: commit:
git add ui assets/settings.html src/settings.rs src/bin/uicheck.rs && git commit -m "ui: settings page in react at parity".
Task 3: Sessions sidebar and transcript
Files:
- Create:
ui/src/pages/Sessions.tsx,ui/src/components/SessionList.tsx,ui/src/components/Transcript.tsx,ui/src/components/NamingStrip.tsx, tests beside each; shadcnsidebar,scroll-area,toggle-group,tooltip,dropdown-menu,command - Modify:
ui/src/App.tsx(shadcnSidebarlayout: sessions left, content right, Settings as the last sidebar item)
Interfaces:
-
Consumes:
apimethodssessions,transcript(session,since: 0,verbatim),speakers.unnamed,speakers.name,speakers.undo,export(format: "markdown", for Copy Markdown), and two bridge-only methods added tosrc/settings.rs:reveal({session}→NSWorkspacereveal in Finder) andclipboard.write({text}→NSPasteboard.generalPasteboardclearContentsthensetString, reply{result: {done: true}}). The transcript loads throughuseLatest. -
Produces: the transcript renders lines in the order the
transcriptmethod returns them (append order, the cursor the MCP plan and search hits use) with no client-side sort; the sidebar lists sessions newest first with name, date, duration and a state badge (live,transcribing,awaiting transcript,done,broken); the transcript shows lines grouped by speaker withmm:ssgutters, a Tidied/Verbatim toggle, Copy Markdown and Reveal buttons, a warnings banner fromsession.json; the naming strip lists unnamed labels with a roster-backed combobox and an Undo button; keyboard:↑/↓move the selection,⌘,opens Settings. -
Step 1: write the failing tests:
SessionListrenders three summaries with the right badges and callsonSelecton click and on ArrowDown;Transcriptrenders speaker groups and toggles verbatim by callingtranscriptwithverbatim: true; Copy Markdown callsexportthenclipboard.writewith the returned text; selecting A then B with A’stranscriptreply released after B’s leaves B’s lines on screen;NamingStripcallsspeakers.namewith the chosen label and name. -
Step 2: run
pnpm test, expect FAIL “cannot find module”. -
Step 3: implement; the page reloads
sessionson everyphaseevent (Task 4 adds the event; until then on mount and after each action). -
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build; add auicheckstep that clicks Copy Markdown and asserts the bridge receivedclipboard.write; runcargo run --bin uicheckand attach its PNG path in the report. -
Step 5: commit:
git add ui assets/settings.html src/settings.rs src/bin/uicheck.rs && git commit -m "ui: sessions and transcript in the page".
Task 4: Live pane and phase events
Files:
- Create:
ui/src/components/LivePane.tsx,ui/src/components/LivePane.test.tsx; shadcnprogress,card - Modify:
src/state.rs(newPhase::failed_dir(&self) -> Option<&Path>, thedirofPhase::Failed,src/state.rs:108-113, besidefailure()),src/window.rs:973-990(renderbecomesrender(&self, phase: &Phase, queue: Option<&str>, mtm), callssettings.bridge.event("phase", …)andsettings.bridge.poll_diarize()),src/menubar.rs:540(the call site passesqueue_line.as_deref(), whichDelegate::renderalready computes at:455) andsrc/menubar.rs:207-275(a newdismissFailure:selector besidestopRecording:that appliesPhase::dismiss,src/state.rs:333, then renders),src/bin/windowcheck.rs:243-244(the tworendercalls passNone),src/settings.rs(Bridge::event; bridge-only methodsrecord.start,record.this_call,record.decline,record.stop,dismiss, each sent up the responder chain exactly as the native Stop button does today,NSApp.sendAction_to_from(sel!(startRecording:|recordThisCall:|notThisOne:|stopRecording:|dismissFailure:), None, …),src/window.rs:800, so the delegate that ownsPhaseCellhandles them; anddiarize.start, which spawns the samesession::diarize_sessionworker threadseparateVoices:spawns today,src/window.rs:882-897, but owned by theBridge:diarizing: RefCell<Option<(String, Receiver<Result<…>>)>>, one job at a time as today (two ONNX diarizers double the memory), so a request while any session is separating is refused with “already separating”, drained by Bridge::poll_diarizefrom everyrendertick, which emits thediarizeevent; the worker claimssession::claim_transcription(dir)first and reports its error through the same event)
Interfaces:
-
Produces: event
phasepayload{"kind": "idle|armed|recording|stopping|failed", "app": string|null, "live": {"id", "elapsed_s", "room_level", "call_level", "status_line"} | null, "queue": string|null, "failure": {"error": string, "session": string|null} | null}built fromPhase::kind(),Phase::live(),Phase::failure()andPhase::failed_dir()(src/state.rs:229,266;failure.sessionis the directory’s basename, null when the failure had no session),Live.meter(session::Meter,src/session.rs:254-305) and the queue line the delegate passes in (Queue::summary(), alive after a recording ends while the phase is alreadyidle); sent on everyrender(the menu bar’s tick) and at most every 100 ms. Eventdiarize{"session", "state": "running|done|failed", "error": string|null}fromBridge::poll_diarize, which outlives the native views Task 5 deletes (SessionList::poll_diarize,src/window.rs:1004, goes with them). -
Produces (page): a live card pinned above the session list while
kindisarmed,recordingorstopping: app, elapsed time, two level meters, the status line, Stop / Not this one; whilekindisfaileda banner instead of the card withfailure.error, a link tofailure.sessionwhen set, and a Dismiss button callingdismiss; whilekindisidleandqueueis set, a one-line “Transcribing…” strip; a “Separate voices” button on a finished session that callsdiarize.start, shows “Separating voices…” until thediarizeevent, and then reloads the transcript or shows the error inline; while any session is separating, every other session’s button is disabled with the tooltip “Separating voices in”. -
Step 1: write the failing test:
LivePanegiven arecordingpayload renders the elapsed time and two meters at the given levels and callsrecord.stopon Stop; givenarmedrenders “Record this call” and “Not this one”; givenfailedwithlive: nullrenders the error text and Dismiss callsdismiss(no crash on the null live); givenidlewith aqueuestring renders that string and no card; Separate voices callsdiarize.startand adiarizedoneevent triggers atranscriptreload. -
Step 2: run
pnpm test, expect FAIL “cannot find module”. -
Step 3: implement both sides;
cargo build --all-targetscompiles every caller ofrender;cargo run --bin windowcheckmust still pass, anduicheckgains a step that injects aphaseevent and asserts the card appears. -
Step 4: run
scripts/check,pnpm test,pnpm check,pnpm lint,pnpm build. -
Step 5: commit:
git add ui assets/settings.html src/settings.rs src/window.rs src/menubar.rs src/state.rs src/bin/windowcheck.rs && git commit -m "ui: live pane driven by phase events".
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: The host swap
Files:
- Modify:
src/window.rs(the WKWebView becomes the window’s content view at full size; the native sidebar, transcript, toolbar, naming strip and live pane views and their actions are deleted:Views:585-616,SessionListtable methods:717-760, actions:780-900,refresh_*:990-1440,layout:1443-1530;open,render,show,select_settings,is_visible,describe_state,demote_to_accessory,policy_name,install_main_menustay) - Modify:
src/bin/windowcheck.rs(asserts the host: window shows,renderemits aphaseevent, the page reports the selected session through auicheck-style script instead of a table row) - Modify:
docs/using/getting-started.md(the window section describes the page),docs/developing/settings-and-ui.md(the architecture paragraph points at ADR 0017)
Interfaces:
-
Consumes: everything Tasks 1–4 built.
-
Produces:
MainWindowwithopen,render,show,select_settings(sets the bridge’s route cell tosettingsand sends eventnavigate {"page": "settings"}, which the bridge queues until the page’sinitif the page is not up yet),is_visible,describe_state(window frame plusSettingsPane::describe). -
Step 1: read
src/window.rsend to end and list, in the report, every function removed. -
Step 2: delete the native views and make the web view the content view; keep the accessory/regular policy behaviour (
demote_to_accessory) unchanged. -
Step 3: run
scripts/check, expectCHECK OK;cargo run --bin windowcheckandcargo run --bin uicheck, quote their last lines;node .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 4: commit:
git add src/window.rs src/bin/windowcheck.rs docs/using/getting-started.md docs/developing/settings-and-ui.md && git commit -m "window: the page is the window".
Task 6: Polish pass against the HIG
Files:
- Modify:
ui/src/styles/globals.css, the components from Tasks 2–4 - Create:
docs/developing/ui.md(frontmattertitle: "The window",sidebar.order: 30)
Interfaces:
-
Produces: focus rings on every control, empty states (no sessions; session without words; search later), loading skeletons while a
callis pending,prefers-reduced-motionrespected,⌘,⌘R⌘S⌘0shortcuts matching the menu, 13 px base text, 11 px secondary, sidebar width 260 px, transcript measure 68ch, a screenshot of light and dark fromuicheckembedded indocs/developing/ui.md(PNG committed underdocs/developing/img/). -
Step 1: write the failing tests:
Apprenders the empty state text whensessionsis[]; the⌘,keydown navigates to Settings; every button inSettingsandTranscripthas an accessible name (query by role, none anonymous). -
Step 2: run
pnpm test, expect FAIL on the new assertions. -
Step 3: implement; run
cargo run --bin uichecktwice (system light, then with the page’sdarkclass forced through the script) and save both PNG files. -
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 docs/developing/ui.md docs/developing/img && git commit -m "ui: polish against the HIG".