Skip to content

Plan: one web page for the window

Updated View as Markdown

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, target safari18); nothing is fetched at runtime. Build output stays assets/settings.html.
  • Every dependency is pinned to the exact version pnpm add resolves at task time and recorded in ui/package.json; the lockfile is committed; CI runs pnpm install --frozen-lockfile.
  • Look: system font stack (-apple-system, system-ui), shadcn neutral base colour, --radius 0.5rem, HIG spacing (8 pt grid), light/dark from prefers-color-scheme (the dark class is set from a matchMedia listener, no toggle), sidebar uses the sidebar tokens. 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/, or loop/ 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_receive parses {id, method, params} and answers), src/settings.rs:269-319 (push becomes event("config", …))

Interfaces:

  • Produces (page): bridge.call<T>(method: string, params?: object): Promise<T> posting {"id": <n>, "method", "params"} to window.webkit.messageHandlers.ambient, resolving when Rust calls window.ambient.reply(id, {result} | {error: {kind, message}}); bridge.on(name, handler) for window.ambient.event(name, payload); a FakeBridge in ui/src/test/fake-bridge.ts that 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 (a useLatest(call) hook in ui/src/lib/latest.ts that all data-loading components use).

  • Produces (Rust): Bridge routes every message to api::call with the real Paths, replying via evaluateJavaScript("window.ambient.reply(<id>, <json>)"); Bridge::event(&self, name, payload), which queues the payload while the page has not yet called init and flushes the queue on init; the page calls init once on mount and receives {route: "sessions"|"settings"}, the route Rust holds in a Cell that select_settings sets (so a cold launch through Settings… lands on Settings whether the event or the page comes first); the old Patch path is deleted in Task 2, so this task keeps handle for messages without id.

  • Step 1: bootstrap: pnpm add react react-dom lucide-react class-variance-authority clsx tailwind-merge tailwindcss @tailwindcss/vite tw-animate-css and pnpm add -D @vitejs/plugin-react @types/react @types/react-dom vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event, then pnpm dlx shadcn@latest init with base colour neutral, CSS variables on, @/ alias to ui/src; pnpm dlx shadcn@latest add button. Keep vite-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 with id: 1 and resolves when window.ambient.reply(1, {result: {}}) is invoked; an {error} reply rejects with message; on("config", h) is called by window.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); App calls init on mount, renders the route it returns, and switches route on a navigate {"page"} event (window.ambient.event("navigate", {page: "settings"}) after mount shows Settings). On the Rust side, a test on the pure queue: an event before init is held and delivered after it, in order. Run pnpm test, expect FAIL “cannot find module ./bridge”.

  • Step 3: implement bridge.ts, App.tsx rendering a shadcn Button labelled “Ambient”, and the Rust side. pnpm test, pnpm check, pnpm lint, pnpm build all pass; cargo run --bin uicheck still 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, expect CHECK OK; quote pnpm 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 (delete Patch, Assign, handle, push; keep Bridge::stashed and the scope logic behind it as a bridge-only method capture.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 calls config.get, config.set, capture.scope, roster.*, speakers.*, devices, and pick_app/pick_dir, which stay native panels answered through the same reply path), src/bin/uicheck.rs (drive the new controls by data-testid)

Interfaces:

  • Consumes: api methods config.get (whose latest_session names the session the unnamed-speaker control asks speakers.unnamed and speakers.name about; 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_app and pick_dir (native NSOpenPanel, 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 a data-testid; config.set while recording is refused with the API’s message shown inline.

  • Step 1: write the failing tests: with a FakeBridge returning a config, the page renders the mic select with the devices, toggling diarize calls config.set {key:"diarize", value:"false"}, choosing Everything calls capture.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 calls roster.add, and a config.set error is shown as text containing “recording”.

  • Step 2: run pnpm test, expect FAIL “cannot find module ./pages/Settings”.

  • Step 3: implement; update uicheck to click the new test ids and to print every message the bridge receives, as it does today. Run cargo run --bin uicheck and quote its last three lines.

  • Step 4: run scripts/check, expect CHECK 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; shadcn sidebar, scroll-area, toggle-group, tooltip, dropdown-menu, command
  • Modify: ui/src/App.tsx (shadcn Sidebar layout: sessions left, content right, Settings as the last sidebar item)

Interfaces:

  • Consumes: api methods sessions, transcript (session, since: 0, verbatim), speakers.unnamed, speakers.name, speakers.undo, export (format: "markdown", for Copy Markdown), and two bridge-only methods added to src/settings.rs: reveal ({session} → NSWorkspace reveal in Finder) and clipboard.write ({text} → NSPasteboard.generalPasteboard clearContents then setString, reply {result: {done: true}}). The transcript loads through useLatest.

  • Produces: the transcript renders lines in the order the transcript method 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 with mm:ss gutters, a Tidied/Verbatim toggle, Copy Markdown and Reveal buttons, a warnings banner from session.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: SessionList renders three summaries with the right badges and calls onSelect on click and on ArrowDown; Transcript renders speaker groups and toggles verbatim by calling transcript with verbatim: true; Copy Markdown calls export then clipboard.write with the returned text; selecting A then B with A’s transcript reply released after B’s leaves B’s lines on screen; NamingStrip calls speakers.name with the chosen label and name.

  • Step 2: run pnpm test, expect FAIL “cannot find module”.

  • Step 3: implement; the page reloads sessions on every phase event (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 a uicheck step that clicks Copy Markdown and asserts the bridge received clipboard.write; run cargo run --bin uicheck and 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; shadcn progress, card
  • Modify: src/state.rs (new Phase::failed_dir(&self) -> Option<&Path>, the dir of Phase::Failed, src/state.rs:108-113, beside failure()), src/window.rs:973-990 (render becomes render(&self, phase: &Phase, queue: Option<&str>, mtm), calls settings.bridge.event("phase", …) and settings.bridge.poll_diarize()), src/menubar.rs:540 (the call site passes queue_line.as_deref(), which Delegate::render already computes at :455) and src/menubar.rs:207-275 (a new dismissFailure: selector beside stopRecording: that applies Phase::dismiss, src/state.rs:333, then renders), src/bin/windowcheck.rs:243-244 (the two render calls pass None), src/settings.rs (Bridge::event; bridge-only methods record.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 owns PhaseCell handles them; and diarize.start, which spawns the same session::diarize_session worker thread separateVoices: spawns today, src/window.rs:882-897, but owned by the Bridge: 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_diarize from every render tick, which emits the diarize event; the worker claims session::claim_transcription(dir) first and reports its error through the same event)

Interfaces:

  • Produces: event phase payload {"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 from Phase::kind(), Phase::live(), Phase::failure() and Phase::failed_dir() (src/state.rs:229,266; failure.session is 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 already idle); sent on every render (the menu bar’s tick) and at most every 100 ms. Event diarize {"session", "state": "running|done|failed", "error": string|null} from Bridge::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 kind is armed, recording or stopping: app, elapsed time, two level meters, the status line, Stop / Not this one; while kind is failed a banner instead of the card with failure.error, a link to failure.session when set, and a Dismiss button calling dismiss; while kind is idle and queue is set, a one-line “Transcribing…” strip; a “Separate voices” button on a finished session that calls diarize.start, shows “Separating voices…” until the diarize event, 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: LivePane given a recording payload renders the elapsed time and two meters at the given levels and calls record.stop on Stop; given armed renders “Record this call” and “Not this one”; given failed with live: null renders the error text and Dismiss calls dismiss (no crash on the null live); given idle with a queue string renders that string and no card; Separate voices calls diarize.start and a diarize done event triggers a transcript reload.

  • Step 2: run pnpm test, expect FAIL “cannot find module”.

  • Step 3: implement both sides; cargo build --all-targets compiles every caller of render; cargo run --bin windowcheck must still pass, and uicheck gains a step that injects a phase event 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, SessionList table 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_menu stay)
  • Modify: src/bin/windowcheck.rs (asserts the host: window shows, render emits a phase event, the page reports the selected session through a uicheck-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: MainWindow with open, render, show, select_settings (sets the bridge’s route cell to settings and sends event navigate {"page": "settings"}, which the bridge queues until the page’s init if the page is not up yet), is_visible, describe_state (window frame plus SettingsPane::describe).

  • Step 1: read src/window.rs end 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, expect CHECK OK; cargo run --bin windowcheck and cargo run --bin uicheck, quote their last lines; node .github/scripts/check-links.mjs, expect 0 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 (frontmatter title: "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 call is pending, prefers-reduced-motion respected, ⌘, ⌘R ⌘S ⌘0 shortcuts matching the menu, 13 px base text, 11 px secondary, sidebar width 260 px, transcript measure 68ch, a screenshot of light and dark from uicheck embedded in docs/developing/ui.md (PNG committed under docs/developing/img/).

  • Step 1: write the failing tests: App renders the empty state text when sessions is []; the ⌘, keydown navigates to Settings; every button in Settings and Transcript has 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 uicheck twice (system light, then with the page’s dark class 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, expect 0 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".

Open Questions

Navigation

Type to search…

↑↓ navigate↵ selectEsc close