---
title: "Plan: one web page for the window"
---

> Documentation Index
> Fetch the complete documentation index at: https://jasonm4130-labs.github.io/ambient/llms.txt
> Use this file to discover all available pages before exploring further.

# Plan: one web page for the window

# Plan: one web page for the window

**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](../adr/0017-one-web-page-for-the-window.md)).
**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 <session>", 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 <session>".

- [ ] **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](2026-09-06-nightwatch-review.md) 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

Source: https://jasonm4130-labs.github.io/ambient/plans/2026-09-05-ui-shell/index.mdx
