From eecaa48e2f15d58b32d89d3a7e967e7f40f919b3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 12:25:12 -0700 Subject: [PATCH 1/8] Enroll a remote Host from Settings, not the devtools console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting a machine to a coordinating server was a `window.dormouseRemoteHost` console call — fine as a POC seam, but it is the one step a self-hoster cannot skip. Add a Remote control section to the app-global Settings dialog over the same enroll/status/reconnect/clearEnrollment commands. Un-enrolled it is a three-field form; enrolled it shows the server, the relay connection state and the paired-device count, with Disconnect and — only on `displaced` — Reconnect. That also fills a gap the spec admitted to: nothing surfaced `displaced`, the one connection state whose recovery is an explicit user act. It renders nothing where `getPlatform().remoteHost` is absent, so the website and lib dev server are unchanged. The store is deliberately independent of the lazily-imported RemotePairingModalHost chunk — Settings is in the main chunk, and importing the pairing module would pull the whole remote-host stack into every host's main bundle. Handling a bearer credential, so: the setup password goes straight through to the service and is cleared on success, `hostToken` never enters the webview realm, an origin outside the build's baked allowlist is refused before the password leaves the machine and that refusal is what the form renders, and Disconnect confirms because it forces every paired phone to re-pair. The connection is polled every 2s while the section is mounted. The service's `status` event fires only when `enrolled` changes, so `connecting -> connected` arrives with no event at all and the dialog would otherwise read as permanently "Connecting…". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi --- DESIGN.md | 1 + SELF_HOST.md | 9 +- docs/specs/alert.md | 2 +- docs/specs/server.md | 60 +++- .../components/RemoteControlSection.test.tsx | 270 ++++++++++++++++++ lib/src/components/RemoteControlSection.tsx | 265 +++++++++++++++++ lib/src/components/SettingsDialog.tsx | 8 +- lib/src/components/design.tsx | 28 ++ lib/src/remote/host/host-status-store.ts | 178 ++++++++++++ 9 files changed, 812 insertions(+), 9 deletions(-) create mode 100644 lib/src/components/RemoteControlSection.test.tsx create mode 100644 lib/src/components/RemoteControlSection.tsx create mode 100644 lib/src/remote/host/host-status-store.ts diff --git a/DESIGN.md b/DESIGN.md index edb19934..00ea0142 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -231,6 +231,7 @@ The system uses **raised surfaces**, not "cards." There are no nested cards. The ### Inputs - Used by `ThemePicker`. Style: `bg-input-bg`, `border border-input-border`, `rounded`, `font-mono`, `text-sm`. - **Focus:** native browser focus outline; this is acceptable because the entire input lives inside a raised surface that already has `shadow-2xl` and a border. +- **Form fields inside a dialog** use the underlined pair in `design.tsx` instead, so a form mixing them reads as one: `NumericInput` for a number (filtered at the keystroke, sized in `ch`) and `TextInput` for a string (full width, `type` passed through — `type="password"` for a credential). The app has no checkbox anywhere: a boolean is an `OnOffSwitch`. ### Navigation diff --git a/SELF_HOST.md b/SELF_HOST.md index 5f17e79f..5f403ed6 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -393,8 +393,13 @@ Serve mapping return without rerunning the installer. Complete Pocket passkey setup and Host enrollment using a standalone or VS Code build whose `DORMOUSE_REMOTE_CONNECT_SRC` includes -`https://*.ts.net wss://*.ts.net`. After `account.json`, `hosts.json`, and -`vapid.json` exist (and `push-subscriptions.json` too if push was enabled): +`https://*.ts.net wss://*.ts.net`. Enroll in **Settings → Remote control** +(the sliders icon at the far right of the baseboard): the server origin, the +setup password from `manage show-password`, and a name for this machine. The +`window.dormouseRemoteHost` console hook is the scripting equivalent +(`docs/specs/server.md`, "Remote control, in the Settings dialog"). After +`account.json`, `hosts.json`, and `vapid.json` exist (and +`push-subscriptions.json` too if push was enabled): 1. Record ownership and checksums of every present state file without printing contents. diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3fdf9676..a7a5b3b3 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -281,6 +281,6 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview | | `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment | | `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list | -| `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications | +| `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications, remote control (see [server.md](./server.md)) | | `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs | | `lib/src/components/Door.tsx` | Door bell + TODO display | diff --git a/docs/specs/server.md b/docs/specs/server.md index 5cc375a8..8fe3e51e 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -452,7 +452,7 @@ called — and reaches the service over the `remoteHost:*` bridge, so the consol API's shape is unchanged and its calls are now promises one round trip further away. -* **Enrollment** (console hook, once): server URL + setup password → +* **Enrollment** (Settings dialog, or the console hook, once): server URL + setup password → `POST /api/host/enroll` → the service persists `{ serverUrl, hostId, hostToken, origin, rpId }` through its `HostStateStore` — a 0600 JSON file under the app-data dir in standalone, `SecretStorage` in VS Code — then opens @@ -484,9 +484,11 @@ away. reconnecting on it would evict the newer Host, which would reconnect and evict this one, forever. Coming back is an explicit act — `window.dormouseRemoteHost.reconnect()` (or `RemoteHost.start()`), which - takes the slot back and displaces the other Host in turn. Nothing surfaces - `displaced` in the UI yet; `window.dormouseRemoteHost.status()` reports it as - `connection`, distinct from the retrying `disconnected`. A close event from a + takes the slot back and displaces the other Host in turn. `displaced` is the + one connection state the user has to act on, so it is the only one the + Settings dialog gives a button (Remote control, below); + `window.dormouseRemoteHost.status()` reports it as `connection`, distinct from + the retrying `disconnected`. A close event from a socket the controller no longer owns is ignored, so a dead socket's late eviction cannot stand down the live one. Disposing the service is terminal: an enrollment or ACL read already in flight cannot construct a relay socket @@ -527,6 +529,51 @@ away. existing resize path. The "tethering to \" grey-out display on the local pane is staged — see remote-api.md `## Future`. +### Remote control, in the Settings dialog + +Enrolling is the one step a self-hoster cannot skip, so it is UI rather than a +console incantation: a **Remote control** section at the bottom of the +app-global Settings dialog ([alert.md](./alert.md) -> Settings dialog). Source +of truth: `lib/src/components/RemoteControlSection.tsx` over +`lib/src/remote/host/host-status-store.ts`. + +It renders **nothing at all** where `getPlatform().remoteHost` is absent — the +website and the lib dev server have no Host service behind them, and offering +the form would promise something the build cannot do. That is the same seam the +push-devices line keys on, which is why its `no-host` copy can point at this +section. + +Un-enrolled it is a three-field form (server, setup password, name for this +machine) calling the service's `enroll`; enrolled it shows the server URL, the +relay connection state, and the paired-device count, with `Disconnect` and — +only on `displaced` — `Reconnect`. Rules the UI exists to honor: + +- **The password is passed through, never held.** It goes straight to the + service, which is the party that talks to the server, and is cleared on + success. `hostToken` never comes back into the webview realm: `enroll` + answers `{ hostId, serverUrl }`. +- **Refusals are shown, not swallowed.** An origin outside this build's baked + allowlist is refused before the password leaves the machine (above), and that + error is what the form renders — so the failure reads as "this build will not + talk to that server" rather than as a wrong password. +- **Disconnect asks first**, because clearing the enrollment drops every paired + phone until each pairs again. +- **Status is re-read, not patched, and the connection is polled.** The + service's `status` event carries only `{ enrolled }` — the edge its webview + gate arms on — so every event triggers a full `status` command, and the dialog + re-reads on open since another window may have enrolled meanwhile. The + *connection* moves with no event at all (`connecting -> connected`, + `-> disconnected`, `-> displaced`), so the store also polls every 2 s **while + something is subscribed**, which is the seconds the dialog is open rather than + a standing timer in every window. Without it a machine that finished + connecting a moment after the dialog opened would read as permanently + "Connecting…". + +The `window.dormouseRemoteHost` console hook keeps the same four commands and +remains the scripting seam. Pairing approval is deliberately *not* here: it is a +modal, because it must interrupt +([remote-security-model.md](./remote-security-model.md), Pairing Ceremony). + ## Pocket side (phone) Served by the server, built from `lib`: @@ -597,7 +644,10 @@ scheme. A local server therefore needs the override at build time, which DORMOUSE_REMOTE_CONNECT_SRC='http://localhost:3000 ws://localhost:3000' pnpm dev:standalone ``` -Then enroll once from the devtools console of the standalone webview: +Then enroll once, in **Settings → Remote control** (the sliders icon at the far +right of the baseboard): server `http://localhost:3000`, the setup password, and +a name for this machine. The same thing from the devtools console of the +webview, which is the scripting seam: ```js await window.dormouseRemoteHost.enroll('http://localhost:3000', 'hunter2', 'My Laptop') diff --git a/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx new file mode 100644 index 00000000..49a8a259 --- /dev/null +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -0,0 +1,270 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The store reads `getPlatform().remoteHost`, so the link is the only seam the + * whole section hangs off. Mutable so a test can present a build with no Host + * service behind it, which is a rendering decision rather than an error. + */ +let platform: { remoteHost?: unknown } = {}; + +vi.mock('../lib/platform', () => ({ + IS_MAC: false, + getPlatform: () => platform, +})); + +import { RemoteControlSection } from './RemoteControlSection'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +type Handler = (data: unknown) => void; + +function makeLink(command: (cmd: string, params?: unknown) => Promise) { + const listeners = new Map>(); + return { + command: vi.fn(command), + on: vi.fn((name: string, listener: Handler) => { + const set = listeners.get(name) ?? new Set(); + set.add(listener); + listeners.set(name, set); + return () => set.delete(listener); + }), + respond: vi.fn(), + notify: vi.fn(), + emit(name: string, data: unknown) { + for (const listener of listeners.get(name) ?? []) listener(data); + }, + }; +} + +const NOT_ENROLLED = { + enrolled: false, + serverUrl: null, + hostId: null, + connection: 'idle', + pairedClients: 0, +}; + +function enrolled(overrides: Record = {}) { + return { + enrolled: true, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + ...overrides, + }; +} + +let container: HTMLDivElement; +let root: Root; + +async function render() { + await act(async () => { + root.render(); + }); +} + +function text(): string { + return container.textContent ?? ''; +} + +function buttonLabelled(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; +} + +async function type(selector: string, value: string) { + const input = container.querySelector(selector); + if (!input) throw new Error(`no input for ${selector}`); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + // Unmounting drops the store's last subscriber, which resets it — otherwise + // one test's status would seed the next one's first paint. + await act(async () => root.unmount()); + container.remove(); + platform = {}; + vi.clearAllMocks(); +}); + +describe('RemoteControlSection', () => { + it('renders nothing on a build with no Host service', async () => { + platform = {}; + await render(); + expect(container.innerHTML).toBe(''); + }); + + it('offers the enroll form when the machine is not enrolled', async () => { + platform = { remoteHost: makeLink(async () => NOT_ENROLLED) }; + await render(); + expect(text()).toContain('Connect this machine to a Dormouse server'); + expect(buttonLabelled('Connect')).toBeTruthy(); + }); + + it('keeps Connect disabled until every field is filled', async () => { + platform = { remoteHost: makeLink(async () => NOT_ENROLLED) }; + await render(); + + expect(buttonLabelled('Connect')!.disabled).toBe(true); + await type('input[type="url"]', 'https://laptop.tailnet.ts.net'); + expect(buttonLabelled('Connect')!.disabled).toBe(true); + await type('input[type="password"]', 'hunter2'); + expect(buttonLabelled('Connect')!.disabled).toBe(true); + await type('input:not([type="url"]):not([type="password"])', 'Work laptop'); + expect(buttonLabelled('Connect')!.disabled).toBe(false); + }); + + it('enrolls with trimmed values and re-reads the status', async () => { + let status: unknown = NOT_ENROLLED; + const link = makeLink(async (cmd) => { + if (cmd === 'enroll') { + status = enrolled(); + return { hostId: 'host-1', serverUrl: 'https://laptop.tailnet.ts.net' }; + } + return status; + }); + platform = { remoteHost: link }; + await render(); + + await type('input[type="url"]', ' https://laptop.tailnet.ts.net '); + await type('input[type="password"]', 'hunter2'); + await type('input:not([type="url"]):not([type="password"])', ' Work laptop '); + await act(async () => buttonLabelled('Connect')!.click()); + + expect(link.command).toHaveBeenCalledWith('enroll', { + serverUrl: 'https://laptop.tailnet.ts.net', + password: 'hunter2', + label: 'Work laptop', + }); + // The status re-read after enrolling is what flips the view. + expect(text()).toContain('https://laptop.tailnet.ts.net'); + expect(text()).toContain('Connected'); + }); + + it('surfaces an enrollment refusal instead of silently failing', async () => { + const link = makeLink(async (cmd) => { + if (cmd === 'enroll') throw new Error('server origin is not allowed by this build'); + return NOT_ENROLLED; + }); + platform = { remoteHost: link }; + await render(); + + await type('input[type="url"]', 'https://evil.example.com'); + await type('input[type="password"]', 'hunter2'); + await type('input:not([type="url"]):not([type="password"])', 'Work laptop'); + await act(async () => buttonLabelled('Connect')!.click()); + + expect(text()).toContain('server origin is not allowed by this build'); + // Still on the form, so the user can correct the origin and retry. + expect(buttonLabelled('Connect')).toBeTruthy(); + }); + + it('shows the server and paired-device count when enrolled', async () => { + platform = { remoteHost: makeLink(async () => enrolled({ pairedClients: 2 })) }; + await render(); + expect(text()).toContain('https://laptop.tailnet.ts.net'); + expect(text()).toContain('2 paired devices'); + expect(buttonLabelled('Reconnect')).toBeUndefined(); + }); + + it('offers Reconnect only when the Host was displaced', async () => { + const link = makeLink(async () => enrolled({ connection: 'displaced' })); + platform = { remoteHost: link }; + await render(); + + expect(text()).toContain('took this server’s slot'); + await act(async () => buttonLabelled('Reconnect')!.click()); + expect(link.command).toHaveBeenCalledWith('reconnect'); + }); + + it('confirms before disconnecting, because paired phones must re-pair', async () => { + const link = makeLink(async () => enrolled()); + platform = { remoteHost: link }; + await render(); + + await act(async () => buttonLabelled('Disconnect')!.click()); + expect(link.command).not.toHaveBeenCalledWith('clearEnrollment'); + expect(text()).toContain('Paired phones will need to pair again'); + + await act(async () => buttonLabelled('Disconnect')!.click()); + expect(link.command).toHaveBeenCalledWith('clearEnrollment'); + }); + + it('follows the connection state, which fires no event', async () => { + vi.useFakeTimers(); + try { + let status: unknown = enrolled({ connection: 'connecting' }); + const link = makeLink(async () => status); + platform = { remoteHost: link }; + await render(); + expect(text()).toContain('Connecting…'); + + // `connecting -> connected` does not change `enrolled`, so the service + // sends nothing. Only the poll notices. + status = enrolled({ connection: 'connected' }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(text()).toContain('Connected'); + expect(text()).not.toContain('Connecting…'); + } finally { + vi.useRealTimers(); + } + }); + + it('stops polling once nothing is watching', async () => { + vi.useFakeTimers(); + try { + const link = makeLink(async () => enrolled()); + platform = { remoteHost: link }; + await render(); + await act(async () => root.unmount()); + + const callsAtUnmount = link.command.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(link.command.mock.calls.length).toBe(callsAtUnmount); + // Re-create the root so afterEach's unmount stays valid. + root = createRoot(container); + } finally { + vi.useRealTimers(); + } + }); + + it('re-reads the status when the service announces a change', async () => { + let status: unknown = NOT_ENROLLED; + const link = makeLink(async () => status); + platform = { remoteHost: link }; + await render(); + expect(text()).toContain('Connect this machine to a Dormouse server'); + + // Another window enrolled: the event carries only `{ enrolled }`, so the + // section must re-read rather than patch a field. + status = enrolled(); + await act(async () => { + link.emit('status', { name: 'status', enrolled: true }); + }); + expect(text()).toContain('https://laptop.tailnet.ts.net'); + }); +}); diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx new file mode 100644 index 00000000..7b848682 --- /dev/null +++ b/lib/src/components/RemoteControlSection.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import { TextInput, modalActionButton } from './design'; +import type { RemoteHostStatus } from '../remote/host/remote-host'; +import { + clearRemoteHostEnrollment, + enrollRemoteHost, + getRemoteHostStatusSnapshot, + reconnectRemoteHost, + refreshRemoteHostStatus, + subscribeToRemoteHostStatus, +} from '../remote/host/host-status-store'; + +/** + * How each relay-socket state reads to someone who is not holding the spec. + * `displaced` is the only one that needs the user to act, so it is the only one + * that gets a button (`docs/specs/server.md`, "Relay socket policy"). + */ +function describeConnection(connection: RemoteHostStatus): { text: string; tone: 'ok' | 'warn' | 'muted' } { + switch (connection) { + case 'connected': + return { text: 'Connected', tone: 'ok' }; + case 'connecting': + return { text: 'Connecting…', tone: 'muted' }; + case 'disconnected': + return { text: 'Reconnecting…', tone: 'muted' }; + case 'displaced': + return { + text: 'Another Dormouse instance took this server’s slot. This machine stood down and will not retry on its own.', + tone: 'warn', + }; + case 'stopped': + return { text: 'Stopped', tone: 'muted' }; + case 'idle': + return { text: 'Not connected', tone: 'muted' }; + } +} + +const TONE_CLASS = { + ok: 'text-foreground', + warn: 'text-error', + muted: 'text-muted', +} as const; + +const FIELD_LABEL = 'text-xs text-muted'; + +/** + * Connect this machine to a coordinating server, so a phone running Dormouse + * Pocket can pair with it. + * + * Renders nothing at all on a build with no Host service behind it (the + * website, the lib dev server): there is no Host to enroll, and offering the + * form would promise something the build cannot do. + * + * This is the same `enroll` / `status` / `reconnect` / `clearEnrollment` + * surface as the `window.dormouseRemoteHost` console hook, which stays as the + * scripting seam (`docs/specs/server.md`, "Host side"). Pairing approval is + * *not* here — it is a modal, because it must interrupt + * (`docs/specs/remote-security-model.md`, Pairing Ceremony). + */ +export function RemoteControlSection() { + const state = useSyncExternalStore(subscribeToRemoteHostStatus, getRemoteHostStatusSnapshot); + + // Another window may have enrolled since this dialog last opened, and the + // service pushes `status` only when it changes. + useEffect(() => void refreshRemoteHostStatus(), []); + + if (state.kind === 'unsupported') return null; + + return ( +
+
Remote control
+ {state.kind === 'loading' ? ( +
Checking…
+ ) : state.kind === 'error' ? ( +
+ Could not reach this machine’s Host service: {state.message} +
+ ) : state.status.enrolled ? ( + + ) : ( + + )} +
+ ); +} + +function EnrolledView({ + serverUrl, + connection, + pairedClients, +}: { + serverUrl: string | null; + connection: RemoteHostStatus; + pairedClients: number; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Disconnecting drops every paired phone until they pair again, so it asks + // once rather than acting on the first click. + const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); + const described = describeConnection(connection); + + const run = useCallback(async (action: () => Promise) => { + setBusy(true); + setError(null); + try { + await action(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + }, []); + + return ( +
+
{serverUrl ?? 'Unknown server'}
+
{described.text}
+
+ {pairedClients === 0 + ? 'No phone has paired with this machine yet.' + : `${pairedClients} paired ${pairedClients === 1 ? 'device' : 'devices'}.`} +
+ + {error ?
{error}
: null} + +
+ {connection === 'displaced' ? ( + + ) : null} + {confirmingDisconnect ? ( + <> + Paired phones will need to pair again. + + + + ) : ( + + )} +
+
+ ); +} + +function EnrollForm() { + const [serverUrl, setServerUrl] = useState(''); + const [password, setPassword] = useState(''); + const [label, setLabel] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const ready = serverUrl.trim() !== '' && password !== '' && label.trim() !== ''; + + const submit = useCallback(async () => { + setBusy(true); + setError(null); + try { + await enrollRemoteHost(serverUrl.trim(), password, label.trim()); + // Only on success: a failed enroll is usually a typo in one of the other + // fields, and clearing the password would make every retry a re-fetch + // from the password manager. + setPassword(''); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + }, [serverUrl, password, label]); + + return ( +
{ + e.preventDefault(); + if (ready && !busy) void submit(); + }} + > +
+ Connect this machine to a Dormouse server to control it from your phone. +
+ + + + + + + + {error ?
{error}
: null} + +
+ +
+
+ ); +} diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index 08fc38cb..ed7200ae 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -12,6 +12,7 @@ import { import { ThemePicker } from './ThemePicker'; import { ShellPicker } from './ShellPicker'; import { WatchedCommandList } from './WatchedCommandList'; +import { RemoteControlSection } from './RemoteControlSection'; import { getPlatform } from '../lib/platform'; import { getShellsSnapshot, subscribeToShells } from '../lib/shell-store'; import { @@ -45,7 +46,7 @@ const SECTION = 'mt-4 border-t border-border pt-3'; function describePushTargets(push: PushDevicesState): string { if (push.status === 'loading') return 'Looking for devices…'; if (push.status === 'error') return 'Could not reach the server to list devices.'; - if (push.status === 'no-host') return 'Connect this machine to a Dormouse server to send push.'; + if (push.status === 'no-host') return 'Connect this machine to a Dormouse server below to send push.'; if (push.devices.length === 0) { return 'No device paired with this machine has enabled alerts in Dormouse Pocket yet.'; } @@ -184,6 +185,11 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { > {describePushTargets(push)} + + {/* Last, and directly under the push section that points at it: push is + the feature that makes a reader care, and "no Host" is the reason it + has nowhere to go. Renders nothing on a build with no Host service. */} + ); } diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 7de4c78c..9b289438 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -328,6 +328,34 @@ export const NumericInput = forwardRef( }, ); +export type TextInputProps = Omit, 'onChange' | 'value'> & { + value: string; + onChange: (next: string) => void; +}; + +/** + * A full-width underlined text field — the string counterpart to + * {@link NumericInput}, sharing its underline so a dialog mixing the two reads + * as one form. Unlike NumericInput it filters nothing and sets no `type`, so a + * caller passes `type="password"` for a credential. + */ +export const TextInput = forwardRef( + function TextInput({ value, onChange, className, ...props }, ref) { + return ( + onChange(e.target.value)} + className={clsx( + 'w-full border-0 border-b border-border bg-transparent px-0.5 py-0.5 font-mono text-foreground outline-none placeholder:text-muted focus:border-focus-ring', + className, + )} + {...props} + /> + ); + }, +); + /** * Left margin that lines content up under an `OnOffSwitch`'s label rather than * its pill: the switch's `w-14` plus the usual `gap-3` between them. Lives here diff --git a/lib/src/remote/host/host-status-store.ts b/lib/src/remote/host/host-status-store.ts new file mode 100644 index 00000000..8a5b0f1e --- /dev/null +++ b/lib/src/remote/host/host-status-store.ts @@ -0,0 +1,178 @@ +/** + * The remote-Host status the Settings dialog renders, as an external store. + * + * The Host is a service in the process that owns the PTYs, so everything here + * is one round trip away over the `remoteHost` link (`activation.ts`). This + * module holds no Host, no relay socket and no ACL — it asks and mirrors. + * + * Deliberately independent of `installRemoteHostConsoleHook`: that lives in the + * lazily-loaded pairing-modal chunk, while Settings is in the main one. Both + * subscribe to the same service events, and `link.on` supports either arriving + * first, so the dialog works whether or not the pairing chunk has loaded. + * + * The service's `status` event carries only `{ enrolled }` + * (`service-protocol.ts` -> `HostStatusEvent`), which is enough to know the + * answer changed but not what it changed to — so every event re-reads the full + * status rather than patching a field. + */ + +import type { RemoteHostConsoleStatus } from '../../host/remote/service-protocol'; +import { getPlatform } from '../../lib/platform'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +/** + * `unsupported` is a build with no Host service behind it (the website, the + * lib dev server) — not a failure, and the section renders nothing at all. + * It is distinct from `error`, which means there is a service and it refused. + */ +export type RemoteHostStatusState = + | { kind: 'unsupported' } + | { kind: 'loading' } + | { kind: 'ready'; status: RemoteHostConsoleStatus } + | { kind: 'error'; message: string }; + +const UNSUPPORTED: RemoteHostStatusState = { kind: 'unsupported' }; +const LOADING: RemoteHostStatusState = { kind: 'loading' }; + +let state: RemoteHostStatusState = LOADING; +const listeners = new Set<() => void>(); +let unsubscribeFromLink: (() => void) | null = null; +let pollTimer: ReturnType | null = null; + +/** + * The service's `status` event fires only when `enrolled` changes, because that + * is the edge its webview gate arms on. The *connection* moves underneath it + * with no event at all: `connecting -> connected` on a normal start, + * `connected -> disconnected` on a dropped relay, `-> displaced` when another + * instance takes the slot. Without a poll the dialog would show whichever state + * happened to be true the instant it opened — a machine that connected a second + * later reads as permanently "Connecting…". + * + * Polling only while something is subscribed keeps this to the seconds the + * dialog is actually open, rather than a standing timer on every window. + */ +const POLL_MS = 2000; + +/** + * Guards against a stale answer overwriting a newer one: enroll and disconnect + * both refresh, and the dialog may refresh on open while one is still in + * flight. Only the newest read may commit. + */ +let generation = 0; + +function setState(next: RemoteHostStatusState): void { + state = next; + for (const listener of listeners) listener(); +} + +/** + * `getPlatform` throws before `initPlatform`, and a host may simply have no + * service. Both mean the same thing here: nothing to ask. + */ +function link(): RemoteHostLink | undefined { + try { + return getPlatform().remoteHost; + } catch { + return undefined; + } +} + +export function getRemoteHostStatusSnapshot(): RemoteHostStatusState { + return state; +} + +export function subscribeToRemoteHostStatus(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + const active = link(); + if (active) { + unsubscribeFromLink = active.on('status', () => void refreshRemoteHostStatus()); + pollTimer = setInterval(() => void refreshRemoteHostStatus(), POLL_MS); + void refreshRemoteHostStatus(); + } else { + setState(UNSUPPORTED); + } + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeFromLink?.(); + unsubscribeFromLink = null; + if (pollTimer) clearInterval(pollTimer); + pollTimer = null; + // Next mount re-reads rather than showing a snapshot from a previous open, + // which may predate an enrollment made in another window. + state = LOADING; + generation++; + } + }; +} + +/** Re-read the service's status. Safe to call concurrently. */ +export async function refreshRemoteHostStatus(): Promise { + const active = link(); + if (!active) { + setState(UNSUPPORTED); + return; + } + const mine = ++generation; + try { + const status = (await active.command('status')) as RemoteHostConsoleStatus | null; + if (mine !== generation) return; + setState(status ? { kind: 'ready', status } : UNSUPPORTED); + } catch (error) { + if (mine !== generation) return; + setState({ kind: 'error', message: describeError(error) }); + } +} + +/** + * Enroll this machine with a coordinating server. + * + * The password is a bearer credential and is passed straight through to the + * service, which is what talks to the server; it is never stored here. The + * service refuses an origin outside this build's baked relay allowlist *before* + * the password leaves the machine (`docs/specs/server.md`, "Where a Host may + * reach a relay server"), so a mistyped origin fails closed rather than leaking + * it. Rejections propagate verbatim — the caller renders them. + */ +export async function enrollRemoteHost( + serverUrl: string, + password: string, + label: string, +): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + await active.command('enroll', { serverUrl, password, label }); + await refreshRemoteHostStatus(); +} + +/** + * Take the relay slot back after `displaced` — which is terminal by design, so + * nothing reconnects on its own. This displaces the other instance in turn + * (`docs/specs/server.md`, "Relay socket policy"). + */ +export async function reconnectRemoteHost(): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + await active.command('reconnect'); + await refreshRemoteHostStatus(); +} + +/** + * Forget the enrollment. The service awaits the delete before reporting + * un-enrolled, so a failed delete leaves this machine enrolled rather than + * claiming otherwise while the credential is still on disk. + */ +export async function clearRemoteHostEnrollment(): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + await active.command('clearEnrollment'); + await refreshRemoteHostStatus(); +} + +function describeError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === 'string' && error) return error; + return 'The Host service did not answer.'; +} From db77998d169db249e705f50449ae4e30f13bb408 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 18:46:33 -0700 Subject: [PATCH 2/8] test: include remote host story fixture --- lib/src/host/remote/test-remote-host-link.ts | 77 ++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 lib/src/host/remote/test-remote-host-link.ts diff --git a/lib/src/host/remote/test-remote-host-link.ts b/lib/src/host/remote/test-remote-host-link.ts new file mode 100644 index 00000000..5c5f06c6 --- /dev/null +++ b/lib/src/host/remote/test-remote-host-link.ts @@ -0,0 +1,77 @@ +/** + * The fake `RemoteHostLink` and status fixtures the Settings dialog's Remote + * control section is exercised against. + * + * Test-only, and shared on purpose — the same reasoning as + * `lib/src/remote/test-fake-socket.ts`. `RemoteControlSection` hangs entirely + * off `getPlatform().remoteHost`, so its unit test and its stories need the + * same two things: a link that answers `status`, and a + * {@link RemoteHostConsoleStatus} to answer it with. Kept typed here, next to + * the interface it fixtures, so adding a field to that interface breaks this + * file rather than letting one caller quietly keep asserting the old shape. + * + * Imports no test framework: the Storybook preview and the story bundle load + * this, and neither may pull `vitest` in (the same rule `lib/tsconfig.app.json` + * records for `wall-test-utils.ts`). Callers that want spies wrap these. + */ + +import type { RemoteHostConsoleStatus } from './service-protocol'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +/** A machine that has never enrolled: the section shows its three-field form. */ +export const UNENROLLED_STATUS: RemoteHostConsoleStatus = { + enrolled: false, + serverUrl: null, + hostId: null, + connection: 'idle', + pairedClients: 0, +}; + +/** An enrolled machine, with the fields a caller is likely to vary. */ +export function enrolledStatus( + over: Partial = {}, +): RemoteHostConsoleStatus { + return { + enrolled: true, + serverUrl: 'https://ned-mac.tail9c2f1.ts.net', + hostId: 'host-6f1c2a90', + connection: 'connected', + pairedClients: 0, + ...over, + }; +} + +/** What {@link makeStubRemoteHostLink} should answer. */ +export interface PrimedRemoteHost { + /** What `status` answers. */ + status?: RemoteHostConsoleStatus; + /** Make `status` reject — "could not reach this machine's Host service". */ + statusError?: string; + /** Make `enroll` reject — the refused-origin case the form renders inline. */ + enrollError?: string; +} + +/** + * A link that answers from a fixed status rather than a real Host service. + * + * Deliberately not a scenario engine: a story is one frame, so `enroll`, + * `reconnect` and `clearEnrollment` resolve without changing the answer. The + * exception is `enrollError`, because a refused origin is a state the form must + * render (`docs/specs/server.md`, "Remote control, in the Settings dialog") and + * a rejected `enroll` is the only way to reach it. + */ +export function makeStubRemoteHostLink(primed: PrimedRemoteHost): RemoteHostLink { + return { + command: async (cmd) => { + if (cmd === 'status') { + if (primed.statusError) throw new Error(primed.statusError); + return primed.status ?? UNENROLLED_STATUS; + } + if (cmd === 'enroll' && primed.enrollError) throw new Error(primed.enrollError); + return null; + }, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; +} From c7ff2aa73ef330ce4eee7131fd7b9553413ead37 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 18:47:02 -0700 Subject: [PATCH 3/8] docs: include remote pairing walkthrough --- docs/stories/pairing.mdx | 337 ++++++++++++++++++ .../stories/RemoteControlSection.stories.tsx | 163 +++++++++ 2 files changed, 500 insertions(+) create mode 100644 docs/stories/pairing.mdx create mode 100644 lib/src/stories/RemoteControlSection.stories.tsx diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx new file mode 100644 index 00000000..a844f41b --- /dev/null +++ b/docs/stories/pairing.mdx @@ -0,0 +1,337 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; + +import * as RemoteControl from '../../lib/src/stories/RemoteControlSection.stories'; +import * as Pairing from '../../lib/src/stories/RemotePairingModal.stories'; +import * as SetupOrSignin from '../../lib/src/stories/SetupOrSignin.stories'; +import * as HostsView from '../../lib/src/stories/HostsView.stories'; +import * as PocketWall from '../../lib/src/stories/PocketWall.stories'; + +{/* Chromatic already snapshots each of the 13 embedded stories on its own, and + this is the only entry whose capture races six lazy iframes — so a diff here + would be a loader caught mid-boot, not a design change. */} + + +# Pairing a phone with your laptop + +Everything it takes to control a laptop's terminals from a phone, in the order +you meet it: stand up a server, build a Host that is allowed to reach it, enroll +the laptop, make a passkey on the phone, approve the pairing, connect. + +The screens below are the real components, rendered from the same stories +Chromatic watches. They are static — nothing here is wired to a live server. +Two of them carry `autoplay` because a docs page does not run a story's `play` +by default, and their state — an expanded disclosure, a painted terminal — only +exists after it runs. + +> **This is a walkthrough, not a spec.** It lives outside `docs/specs/`, so the +> fold discipline in `AGENTS.md` does not apply to it and `spec-lint` does not +> check it. Where it disagrees with a spec, the spec is right. The three it +> leans on are `server.md` (what the server and the installer do), +> `remote-security-model.md` (why the ceremony has the shape it has), and +> `pocket-app.md` (the phone). + +--- + +## 0. Three parties, four layers + +| Term | Who that is here | +| --- | --- | +| **Client** (Dormouse Pocket) | the phone. Holds a passkey and a device key, and signs Host challenges. | +| **Host** (Dormouse Terminal) | the laptop being controlled. Holds the ACL. **Final authority for access decisions.** | +| **Server** | the coordinating service: accounts, passkey registration, challenges, and relay. *Not* the authority for Host access. | + +Access is deliberately split across four layers, and a connection needs all of +them to agree: + +| Layer | Responsibility | +| --- | --- | +| Passkey | Fresh user presence | +| Device Key | Long-lived client identity | +| Host ACL | Authorization | +| Host | Final access decision | + +The consequence that shapes every screen after this one: **adding a passkey to +your account does not grant a new phone access to any machine.** Passkeys sync; +authorization does not. Each browser must be approved on each laptop, in person, +once. + +--- + +## 1. Stand up the server + +The whole self-host story today is one coordinating server on your own Mac, +reachable only from your tailnet. One idempotent command builds the current +checkout into a self-contained release and installs it: + +```sh +./deploy/local/install-macos.sh +``` + +It installs under your home directory, needs no `sudo`, and wires up a +LaunchAgent plus Tailscale Serve: + +```text +LaunchAgent (RunAtLoad + KeepAlive) + | + v +Dormouse Node server on 127.0.0.1:3100 + | + v +tailscale serve --bg terminates private HTTPS + | + v +https://..ts.net +``` + +Then check it and read the generated password out in your own terminal: + +```sh +"$HOME/Library/Application Support/Dormouse Server/bin/manage" verify +"$HOME/Library/Application Support/Dormouse Server/bin/manage" show-password +``` + +Two things to carry forward: + +- **That origin is durable WebAuthn identity.** The passkey you are about to + create is bound to `https://..ts.net` byte for byte. + Renaming or re-enrolling the Tailscale node means redoing the passkey and + every Host enrollment — which is why the installer stops rather than rewrite + a changed origin for you. +- **HTTPS is not optional.** WebAuthn needs a secure context and only + `localhost` is exempt, so a real phone needs the TLS origin. For a + desktop-only dev loop, `DORMOUSE_SETUP_PASSWORD=hunter2 pnpm dev:pocket-server` + on `localhost:3000` is the shortcut. + +The setup password gates exactly two things: creating the one account, and +enrolling a Host. It never leaves your machine except to those two endpoints. + +--- + +## 2. Build a Host that is allowed to reach it + +Shipped Dormouse builds bake in a relay allowlist of +`https://*.dormouse.sh wss://*.dormouse.sh` and nothing else, enforced inside +the Node process that holds the relay socket rather than by any webview CSP. A +self-host origin needs a local build that widens it: + +```sh +DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone +DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode +``` + +Skipping this has a specific, recognizable symptom, and you will see it in the +next section rather than as a mysterious failure: enrollment is refused +locally, before the setup password leaves the machine. + +--- + +## 3. Enroll the laptop + +**Settings → Remote control**, at the bottom of the app-global Settings dialog +(the sliders icon at the far right of the baseboard). Three fields. + + + +This is UI rather than a console incantation because it is the one step a +self-hoster cannot skip. Three rules it exists to honor: + +- **The password is passed through, never held.** It goes straight to the Host + service — the party that actually talks to the server — and is cleared on + success only. A failed enroll is usually a typo in one of the other fields, + and wiping the password would make every retry a fresh trip to the password + manager. +- **Refusals are shown, not swallowed.** This is section 2's symptom: + + + + The build refuses the origin before the password is sent anywhere. Saying so + in those words is what keeps it from reading as a wrong password. + +- **The bearer token never comes back.** Enrollment answers + `{ hostId, serverUrl }`; the `hostToken` stays in the Host service and on + disk, and never enters the webview. + +On success the section flips to the enrolled view. Note the last line — +enrolling connects the machine to a server; it pairs it with nothing. + + + +--- + +## 4. Make a passkey on the phone + +Open the same origin on a tailnet-connected phone. Pocket loads. + + + +There is nothing to sign in with yet, so the first visit goes through the +disclosure: the setup password from `manage show-password`, plus a label for +this passkey. + + + +That one action creates the account's first passkey and signs in with it. Two +things you cannot see happen at the same time: + +- A **device key** is minted — a non-extractable ECDSA P-256 keypair kept in + IndexedDB. This browser can sign with it and can never export it. It is this + browser's permanent identity, and it is what the laptop is about to + authorize. +- The session's **presence stamp** is set. Pairing requires a passkey assertion + from the last 30 seconds, so signing in is what makes the next step possible. + +> **On iOS, install to the Home Screen before you do any of this.** The +> installed app is a separate storage partition from the Safari tab, so it gets +> its own device key and needs its own pairing approval. Setting up in the tab +> first means doing all of it twice. iOS also delivers Web Push only to an +> installed app. + +--- + +## 5. Pick a machine + + + +One row per enrolled Host. A row is offline when no Dormouse instance currently +holds that server's relay slot. **Pair** appears only on an online, unpaired +Host — everything else is a Connect away. + +--- + +## 6. The pairing ceremony + +Tapping **Pair** sends the phone's identity up to the server and across to the +laptop: + +```text +phone server host (laptop) + |-- signin (passkey) -------->| | + | generate device key | | + |-- pair -------------------->|-- pair --------------------->| approval modal + | | | user clicks Approve + |<-- pair-result -------------|<-- pair-result --------------| ACL record saved +``` + +The server checks what it can: that the credential is a registered passkey of +the account, that its public-key hash matches the stored key, and that the +session's last verified assertion is within 30 seconds. A malformed or stale +request is answered there and **never reaches the approval modal** — so what a +person is asked to look at is always a real, fresh request. (A stale one costs +the phone one extra biometric prompt and a retry, not a restart.) + +Then the laptop interrupts: + + + +This modal is the whole security model in one screen, which is why it is a +modal and not a settings row. + +- **Local approval is the only path into the ACL.** Not signing in, not owning + the account, not holding a synced passkey. Someone with your account and your + passkey still gets no further than this dialog on a machine they are not + standing at. +- **It has to not read as a formality.** Hence *"Approve only if you are the + one asking"* rather than a bare Are-you-sure, and hence Deny holding initial + focus. +- **The three rows are the identity being authorized.** Device is the label the + phone suggested, Account is who signed in, and Key is the first 8 characters + of that browser's device public key — the part that distinguishes two + Clients on the same physical phone. +- **"Adds it to this machine only"** is doing real work. The ACL is per-Host + and local; your other laptops are unaffected and will each ask separately. + +Approving writes one `HostAclRecord` binding *that passkey credential* to *that +device key* — a pair, not either one alone. The approval is bound to the +ceremony's immutable `pairingId`, so a request that changed underneath a +displayed modal is rejected rather than silently approving a device nobody was +shown. + +Two cases the copy has to survive: a phone that suggested no label, and an +account and label long enough to wrap. + + + + + +--- + +## 7. Connect + +Pairing is once. Connecting is every session, and it re-proves everything: + +```text +phone server host + |-- connect {hostId} -------->|-- connect {clientId} ------->| + |<-- challenge ---------------|<-- challenge (HostChallengeIssuer) + | ONE biometric prompt: | | + | WebAuthn get({challenge}) | | + | + device-key signature | | + |-- ConnectionRequest ------->| server verifies the | + | | assertion itself, then | + | |-- ConnectionRequest -------->| authorizeConnection() + |<-- decision ----------------|<-- decision -----------------| (final authority) + |============ opaque remote-api relay from here ============>| +``` + +A connection succeeds only if all five hold: the passkey proves fresh presence, +the Server recognizes the account, the Host recognizes the passkey credential, +the Host recognizes the device key, and the Client signs a fresh Host challenge +with that device key. The Host decides last and decides alone, regardless of +what the Server claims to have already checked. + +One host challenge feeds both signatures, so the whole thing costs the user a +single Face ID prompt. + +Then the payoff — the same mobile terminal UI, driven by the laptop's real +sessions: + + + +--- + +## 8. Living with it + +Back on the laptop, the section now counts what is paired: + + + +**Displaced** is the one connection state that needs a person. Another Dormouse +instance enrolled with the same `hostId` took the relay slot, and this one stood +down — terminally, on purpose, because two instances fighting over a slot is +worse than one stopping. Reconnect takes it back, and displaces the other in +turn. + + + +**Disconnect** asks first, because forgetting the enrollment drops every paired +phone until each pairs again. + + + +Two limits worth knowing before they surprise you: + +- **Clearing site data destroys the device key.** That is a re-pair, by design: + the identity the laptop authorized is gone — and the storage partition the + key lives in is per-browser, as §4 warns. +- **A dropped WebSocket returns the phone to the Hosts view.** Tap Connect + again; there is no resume protocol. + +--- + +## 9. What is not built yet + +So nothing above reads as a promise: + +- **Revocation is editing a JSON file by hand.** There is no management UI and + no relay frame carries a revocation; a removed record takes effect at the + Host's next authorization check. +- **Approval is Approve/Deny only.** Choosing a standing grant at pairing time + — observe-only versus interactive — is designed but not built. Every paired + session today gets full input. +- **The Host does not show you who is connected.** A viewer list with + per-viewer disconnect is staged. +- **Terminals only.** The shipped protocol carries terminal surfaces and + nothing else — no browser surfaces, no thumbnails, no scrollback. diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx new file mode 100644 index 00000000..857a5fae --- /dev/null +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fireEvent, userEvent, within } from 'storybook/test'; +import { ModalSurface } from '../components/design'; +import { RemoteControlSection } from '../components/RemoteControlSection'; +import { enrolledStatus, UNENROLLED_STATUS } from '../host/remote/test-remote-host-link'; + +/** + * The Settings dialog's Remote control section — the one step a self-hoster + * cannot skip (`docs/specs/server.md`, "Remote control, in the Settings + * dialog"). Rendered on its own rather than through `SettingsDialog` so these + * stories are about the enrollment states themselves; `SettingsDialog`'s + * `WithRemoteControl` covers it in place. + * + * Every state comes from the `primedRemoteHost` parameter, because the section + * reads its whole world from `getPlatform().remoteHost` and renders nothing + * without one. The leading rule is the section's own `border-t` — it normally + * separates it from the push settings above. + */ +function RemoteControlStory() { + return ( +
+ + + +
+ ); +} + +const meta: Meta = { + title: 'Modals/RemoteControlSection', + component: RemoteControlStory, + // Embedded in a docs page, each of these needs its own frame. The section + // reads a module-singleton store (`host-status-store.ts`: `state` is module + // scope, and the link is captured only when `listeners.size === 1`), so N + // sections sharing one JS realm share one status however many links exist — + // and the other stories on that page reset `platform.remoteHost` to + // `undefined` underneath them. Separate realms is the only fix short of + // rebuilding the store around a docs page. An iframe does not grow to its + // content, hence the explicit height; stories taller than this override it. + parameters: { docs: { story: { inline: false, height: '250px' } } }, +}; + +export default meta; +type Story = StoryObj; + +/** + * The status command is a round trip, so every story opens on "Checking…". + * Waiting for the settled text keeps Chromatic off that frame — and asserts the + * story actually reached the state it claims, rather than rendering an empty + * section because the stub never arrived. + */ +function settled(text: string | RegExp) { + return async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await within(canvasElement).findByText(text); + }; +} + +/** A machine that has never enrolled: server, setup password, name. */ +export const Unenrolled: Story = { + parameters: { + primedRemoteHost: { status: UNENROLLED_STATUS }, + docs: { story: { height: '390px' } }, + }, + play: settled('Connect'), +}; + +/** + * The refusal that matters most. A Host bundle only talks to the origins baked + * into it at build time, so a stock build pointed at a self-host server fails + * *before* the password leaves the machine — and the form has to say that, + * rather than let it read as a wrong password. + */ +export const EnrollRefused: Story = { + parameters: { + primedRemoteHost: { + status: UNENROLLED_STATUS, + enrollError: + 'This build will not connect to https://ned-mac.tail9c2f1.ts.net. Allowed: https://*.dormouse.sh wss://*.dormouse.sh', + }, + docs: { story: { height: '440px' } }, + }, + // `fireEvent.change` rather than `userEvent.type`: these are controlled + // inputs, so per-character typing costs a render each — ten seconds to fill + // three fields, long enough that a reader scrolling past sees a half-typed + // form — and typing them without awaiting a render between keystrokes + // (`delay: null`) loses every character but the last. One change event with + // the whole value is what a paste does anyway. + play: async (context) => { + const canvas = within(context.canvasElement); + const fill = (label: string, value: string) => + fireEvent.change(canvas.getByLabelText(label), { target: { value } }); + + await canvas.findByLabelText('Server'); + fill('Server', 'https://ned-mac.tail9c2f1.ts.net'); + fill('Setup password', 'correct horse battery staple'); + fill('Name for this machine', 'Work laptop'); + await userEvent.click(canvas.getByRole('button', { name: 'Connect' })); + await canvas.findByText(/This build will not connect/); + }, +}; + +/** Enrolled, relay socket still opening. No event fires for this → the 2 s poll. */ +export const Connecting: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus({ connection: 'connecting' }) } }, + play: settled('Connecting…'), +}; + +/** Connected, but nothing has paired yet — the state right after enrolling. */ +export const ConnectedNoDevices: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus() } }, + play: settled('No phone has paired with this machine yet.'), +}; + +/** After a successful pairing ceremony. */ +export const ConnectedOneDevice: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus({ pairedClients: 1 }) } }, + play: settled('1 paired device.'), +}; + +/** Plural, and a long tailnet origin exercising the URL line's `break-all`. */ +export const ConnectedManyDevices: Story = { + parameters: { + primedRemoteHost: { + status: enrolledStatus({ + serverUrl: 'https://neds-16-inch-macbook-pro-2026.tail9c2f1.ts.net', + pairedClients: 4, + }), + }, + }, + play: settled('4 paired devices.'), +}; + +/** + * The only connection state with a button. `displaced` is terminal by design — + * another instance took the relay slot and this one stood down — so nothing + * brings it back on its own. + */ +export const Displaced: Story = { + parameters: { + primedRemoteHost: { status: enrolledStatus({ connection: 'displaced', pairedClients: 1 }) }, + docs: { story: { height: '280px' } }, + }, + play: settled(/Another Dormouse instance took/), +}; + +/** Disconnect asks first: it drops every paired phone until each pairs again. */ +export const ConfirmingDisconnect: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus({ pairedClients: 2 }) } }, + play: async (context) => { + const canvas = within(context.canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: 'Disconnect' })); + await canvas.findByText('Paired phones will need to pair again.'); + }, +}; + +/** + * There *is* a Host service and it refused — distinct from a build that has + * none, which renders nothing at all rather than an error. + */ +export const HostServiceError: Story = { + parameters: { primedRemoteHost: { statusError: 'The Host service did not answer.' } }, + play: settled(/Could not reach this machine’s Host service/), +}; From 32d4ba8d26a0e3f6d0e895c5c1b3044287509638 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 19:39:13 -0700 Subject: [PATCH 4/8] fix(remote): stop coalescing status reads that can no longer answer the question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializing the reads fixed the poll superseding a slow read's own timeout, but made every caller join whatever was already in flight — including two that must not. A lifecycle command is one: `enroll` / `reconnect` / `clearEnrollment` each finish by re-reading, and a `status` issued before the command answers the question as it stood beforehand. Joining it reports the old enrollment as though the command had not run, which is the inverse of the delete-first ordering the service uses so a failed delete never claims to have succeeded — here a successful one claims not to have. Losing the last subscriber is the other. `refreshInFlight` outlived the unsubscribe, so closing the dialog during a wedged read and reopening it issued no read at all: the new mount coalesced onto the abandoned promise and sat on "Checking…" until it settled, up to the link's whole 15-second timeout. Both are the same act — drop the read in flight — so both call it. The abandoned read was already neutralized twice over: `generation` moves, so it cannot commit, and its completion callback sees a different in-flight promise, so it cannot clear whatever replaced it. Also points `server.md` at the pairing walkthrough committed alongside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB --- docs/specs/server.md | 22 ++- lib/src/remote/host/host-status-store.test.ts | 180 ++++++++++++++++++ lib/src/remote/host/host-status-store.ts | 76 ++++++-- 3 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 lib/src/remote/host/host-status-store.test.ts diff --git a/docs/specs/server.md b/docs/specs/server.md index a40ddb8e..4d5831b3 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -572,15 +572,31 @@ only on `displaced` — `Reconnect`. Rules the UI exists to honor: *connection* moves with no event at all (`connecting -> connected`, `-> disconnected`, `-> displaced`), so the store also polls every 2 s **while something is subscribed**, which is the seconds the dialog is open rather than - a standing timer in every window. Without it a machine that finished - connecting a moment after the dialog opened would read as permanently - "Connecting…". + a standing timer in every window. Status reads are serialized: ticks that + arrive during a slow read coalesce behind it, so a 15-second Host-service + timeout is allowed to become the visible error instead of being superseded by + newer polls. Without the poll a machine that finished connecting a moment + after the dialog opened would read as permanently "Connecting…". +- **Coalescing stops at anything that changes the answer.** `enroll`, + `reconnect` and `clearEnrollment` each drop the read in flight and start their + own, because a `status` issued before the command answers the question as it + stood beforehand — joining it would report the old enrollment as though the + command had not run, the inverse of the delete-first ordering the service uses + so a failed delete never claims to have succeeded. Losing the last subscriber + drops it for the same reason: a reopened dialog must not be answered with a + status fetched for the closed one, and would otherwise sit on "Checking…" + until that read settled. Source of truth: `dropInFlightRead` in + `lib/src/remote/host/host-status-store.ts`. The `window.dormouseRemoteHost` console hook keeps the same four commands and remains the scripting seam. Pairing approval is deliberately *not* here: it is a modal, because it must interrupt ([remote-security-model.md](./remote-security-model.md), Pairing Ceremony). +`docs/stories/pairing.mdx` walks this section and the pairing modal in sequence +with the rest of the setup, rendering the real components; it is a narrative +Storybook page, not a spec, so this section is what it defers to. + ## Pocket side (phone) Served by the server, built from `lib`: diff --git a/lib/src/remote/host/host-status-store.test.ts b/lib/src/remote/host/host-status-store.test.ts new file mode 100644 index 00000000..5bc6cf9b --- /dev/null +++ b/lib/src/remote/host/host-status-store.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +let remoteHostLink: RemoteHostLink | undefined; + +vi.mock('../../lib/platform', () => ({ + getPlatform: () => ({ remoteHost: remoteHostLink }), +})); + +import { + clearRemoteHostEnrollment, + getRemoteHostStatusSnapshot, + subscribeToRemoteHostStatus, +} from './host-status-store'; + +afterEach(() => { + remoteHostLink = undefined; + vi.useRealTimers(); +}); + +describe('host status polling', () => { + it('lets a slow status timeout commit without overlapping polls superseding it', async () => { + vi.useFakeTimers(); + let activeReads = 0; + let maxActiveReads = 0; + const command = vi.fn( + () => + new Promise((_resolve, reject) => { + activeReads++; + maxActiveReads = Math.max(maxActiveReads, activeReads); + setTimeout(() => { + activeReads--; + reject(new Error('status timed out')); + }, 15_000); + }), + ); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + const unsubscribe = subscribeToRemoteHostStatus(() => {}); + try { + expect(command).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(14_000); + expect(command).toHaveBeenCalledTimes(1); + expect(maxActiveReads).toBe(1); + expect(getRemoteHostStatusSnapshot()).toEqual({ kind: 'loading' }); + + await vi.advanceTimersByTimeAsync(1_000); + expect(maxActiveReads).toBe(1); + expect(getRemoteHostStatusSnapshot()).toEqual({ + kind: 'error', + message: 'status timed out', + }); + } finally { + unsubscribe(); + } + }); +}); + +describe('re-reading after a mutation', () => { + /** + * The poll may coalesce, because any recent answer will do. A lifecycle + * command may not: a `status` issued before the disconnect answers the + * question as it stood beforehand, so joining it would report this machine + * still enrolled after its enrollment was successfully deleted — the exact + * claim the service's delete-first ordering exists to prevent. + */ + it('does not resolve on a status read that predates the command', async () => { + let enrolled = true; + /** Set while the first `status` is deliberately left hanging. */ + let releaseFirstRead: (() => void) | null = null; + + const statusAnswer = () => ({ + enrolled, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + }); + + let seenStatus = 0; + const command = vi.fn(async (cmd: string) => { + if (cmd === 'clearEnrollment') { + enrolled = false; + return null; + } + // Every `status` answers with enrollment as it stood when it was *called*. + const answer = statusAnswer(); + if (++seenStatus > 1) return answer; + return new Promise((resolve) => { + releaseFirstRead = () => resolve(answer); + }); + }); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + const unsubscribe = subscribeToRemoteHostStatus(() => {}); + try { + // Subscribing issued a read that is still in flight, and still says enrolled. + expect(releaseFirstRead).not.toBeNull(); + + // Disconnect, and let its own re-read run to completion... + await clearRemoteHostEnrollment(); + // ...then let the pre-disconnect read land. It must not win. + releaseFirstRead!(); + await Promise.resolve(); + + expect(getRemoteHostStatusSnapshot()).toMatchObject({ + kind: 'ready', + status: { enrolled: false }, + }); + } finally { + unsubscribe(); + } + }); +}); + +describe('re-subscribing', () => { + /** + * Closing the dialog while a read hangs and reopening it must issue a new + * read. Coalescing onto the old one would answer the reopened dialog with a + * status fetched for the closed one — and leave it on "Checking…" until that + * read finally settles, which for a wedged Host service is the link's whole + * command timeout. + */ + it('issues a fresh read rather than joining one left over from a closed dialog', async () => { + const releases: Array<() => void> = []; + const command = vi.fn( + () => + new Promise((resolve) => { + releases.push(() => + resolve({ + enrolled: true, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + }), + ); + }), + ); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + // Open, then close while that first read is still hanging. + subscribeToRemoteHostStatus(() => {})(); + expect(command).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToRemoteHostStatus(() => {}); + try { + expect(command).toHaveBeenCalledTimes(2); + + // The abandoned read landing must not commit for the reopened dialog... + releases[0]!(); + await Promise.resolve(); + expect(getRemoteHostStatusSnapshot()).toEqual({ kind: 'loading' }); + + // ...and the reopened dialog's own read must. + releases[1]!(); + await Promise.resolve(); + expect(getRemoteHostStatusSnapshot()).toMatchObject({ kind: 'ready' }); + } finally { + unsubscribe(); + } + }); +}); + diff --git a/lib/src/remote/host/host-status-store.ts b/lib/src/remote/host/host-status-store.ts index 8a5b0f1e..df7439a8 100644 --- a/lib/src/remote/host/host-status-store.ts +++ b/lib/src/remote/host/host-status-store.ts @@ -38,6 +38,8 @@ let state: RemoteHostStatusState = LOADING; const listeners = new Set<() => void>(); let unsubscribeFromLink: (() => void) | null = null; let pollTimer: ReturnType | null = null; +let refreshInFlight: Promise | null = null; +let refreshAgain = false; /** * The service's `status` event fires only when `enrolled` changes, because that @@ -49,15 +51,13 @@ let pollTimer: ReturnType | null = null; * later reads as permanently "Connecting…". * * Polling only while something is subscribed keeps this to the seconds the - * dialog is actually open, rather than a standing timer on every window. + * dialog is actually open, rather than a standing timer on every window. A + * slow read is never overlapped: ticks coalesce behind it, so its timeout can + * commit instead of every later tick making the eventual failure stale. */ const POLL_MS = 2000; -/** - * Guards against a stale answer overwriting a newer one: enroll and disconnect - * both refresh, and the dialog may refresh on open while one is still in - * flight. Only the newest read may commit. - */ +/** Invalidates an in-flight answer when the last subscriber goes away. */ let generation = 0; function setState(next: RemoteHostStatusState): void { @@ -101,15 +101,65 @@ export function subscribeToRemoteHostStatus(listener: () => void): () => void { if (pollTimer) clearInterval(pollTimer); pollTimer = null; // Next mount re-reads rather than showing a snapshot from a previous open, - // which may predate an enrollment made in another window. + // which may predate an enrollment made in another window. That includes + // dropping a read still in flight: keeping it would have the next mount + // coalesce onto an answer fetched for a dialog that is already closed, + // and sit on "Checking…" until it finally settles. state = LOADING; - generation++; + dropInFlightRead(); } }; } -/** Re-read the service's status. Safe to call concurrently. */ -export async function refreshRemoteHostStatus(): Promise { +/** Re-read the service's status, coalescing calls while one read is in flight. */ +export function refreshRemoteHostStatus(): Promise { + if (refreshInFlight) { + refreshAgain = true; + return refreshInFlight; + } + + const refresh = readRemoteHostStatus(); + refreshInFlight = refresh; + void refresh.then(() => { + if (refreshInFlight !== refresh) return; + refreshInFlight = null; + if (refreshAgain && listeners.size > 0) { + refreshAgain = false; + void refreshRemoteHostStatus(); + } + }); + return refresh; +} + +/** + * Stop coalescing onto the read in flight, because its answer is no longer the + * one anybody is waiting for. + * + * Safe to call at any point: the abandoned read is neutralized twice over — + * `generation` moves, so it cannot commit, and its completion callback sees a + * different in-flight promise, so it cannot clear whatever replaced it. + */ +function dropInFlightRead(): void { + refreshInFlight = null; + refreshAgain = false; + generation++; +} + +/** + * Re-read *after* a mutation this module just made. + * + * Coalescing is right for the poll, where any recent answer will do, and wrong + * here: a read issued before the enroll/disconnect answers the question as it + * stood beforehand, so joining it would report the old enrollment as though the + * command had not run — the inverse of the delete-first ordering the service + * uses so a failed delete never claims to have succeeded. + */ +function refreshAfterMutation(): Promise { + dropInFlightRead(); + return refreshRemoteHostStatus(); +} + +async function readRemoteHostStatus(): Promise { const active = link(); if (!active) { setState(UNSUPPORTED); @@ -144,7 +194,7 @@ export async function enrollRemoteHost( const active = link(); if (!active) throw new Error('This build has no remote Host service.'); await active.command('enroll', { serverUrl, password, label }); - await refreshRemoteHostStatus(); + await refreshAfterMutation(); } /** @@ -156,7 +206,7 @@ export async function reconnectRemoteHost(): Promise { const active = link(); if (!active) throw new Error('This build has no remote Host service.'); await active.command('reconnect'); - await refreshRemoteHostStatus(); + await refreshAfterMutation(); } /** @@ -168,7 +218,7 @@ export async function clearRemoteHostEnrollment(): Promise { const active = link(); if (!active) throw new Error('This build has no remote Host service.'); await active.command('clearEnrollment'); - await refreshRemoteHostStatus(); + await refreshAfterMutation(); } function describeError(error: unknown): string { From 728ef3b05025ce70ac6c775643e524bed4474830 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 19:39:27 -0700 Subject: [PATCH 5/8] build(storybook): render the pairing walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up the MDX page and the stories it embeds. MDX was not supported: `@storybook/addon-docs` was not installed and the stories glob matched only `.stories.tsx` under `lib/src`. Because the walkthrough is a doc about the product rather than about `lib`, it lives at the repo root, which puts it outside Node's resolution path to `lib/node_modules` — hence the exact `@storybook/addon-docs/blocks` alias, resolved from where the package actually is. A root dependency would be worse, not simpler: the root declares none today, and a second copy would render the page's blocks from a different instance than the addon rendering the page. `RemoteControlSection` reads its whole world from `getPlatform().remoteHost`, so the fake adapter gains the field the way it already carries `hostOwnsTheme` and `hostOwnsShells`, and a `primedRemoteHost` parameter installs the shared stub. Its stories render in their own frames: the store behind that section is a module singleton whose link is captured only at `listeners.size === 1`, so sections sharing one realm share one status however many links exist. Chromatic gains `docs/stories/**` — the page would otherwise not rebuild when it changes — but skips the page itself, whose every pixel is one of thirteen stories it already snapshots, and whose capture would otherwise race six lazy iframes. AGENTS.md names the walkthrough so it is not a second docs tree nothing points at; the path is backticked, so spec-lint fails the build if it is ever renamed without updating the pointer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB --- .github/workflows/chromatic.yml | 2 + AGENTS.md | 2 + lib/.storybook/main.ts | 16 ++- lib/.storybook/preview.ts | 18 ++++ lib/package.json | 1 + .../components/RemoteControlSection.test.tsx | 25 ++--- lib/src/lib/platform/fake-adapter.ts | 8 +- lib/src/stories/SettingsDialog.stories.tsx | 20 ++++ pnpm-lock.yaml | 98 +++++++++++++++++++ 9 files changed, 173 insertions(+), 17 deletions(-) diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index a08490dd..680293c3 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -6,9 +6,11 @@ on: - main paths: - 'lib/**' + - 'docs/stories/**' pull_request: paths: - 'lib/**' + - 'docs/stories/**' permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index cead5c14..c1abdf5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,8 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi When updating code covered by a spec, update the spec to match. When the two specs overlap (e.g. pane header elements appear in both), layout.md documents placement and sizing while alert.md documents behavior and visual states. +**Narrative docs are not specs.** `docs/stories/pairing.mdx` is a Storybook page that walks the self-hosted remote-control setup end to end, embedding the real screens from `lib/src/stories/`. It restates specs for narrative flow rather than owning anything, `scripts/spec-lint.mjs` does not check it, and the `## Future` fold does not apply. When a remote spec changes, check whether it needs the same edit — the specs win where they disagree. + When editing specs, keep them concise but do not replace invariants or edge cases with only a code pointer. Use `Source of truth:` for implementation references, and include direction/scope for protocols, command orchestration, and cross-package boundaries. For docs-only compression, spot-check referenced symbols, message directions, and root-vs-package script ownership against code before committing. Every spec that uses Session / Pane / Door / baseboard / passthrough vocabulary leads with a `> See \`docs/specs/glossary.md\` for ...` blockquote (see `layout.md`, `alert.md`, `terminal-state.md`). When introducing glossary vocabulary into a spec that lacks the callout, add it in the same edit. diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index 5f42e176..fb8f80b3 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -1,11 +1,18 @@ import type { StorybookConfig } from '@storybook/react-vite'; import path from 'path'; +import { createRequire } from 'module'; import { fileURLToPath } from 'url'; const here = path.dirname(fileURLToPath(import.meta.url)); +const requireFromHere = createRequire(import.meta.url); const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx)'], + // The narrative walkthrough in `docs/stories/` lives outside this package on + // purpose: it is a doc about the product, not about `lib`, and it references + // stories from here rather than defining any (MDX has not been able to define + // a story since Storybook 7). + stories: ['../src/**/*.stories.@(ts|tsx)', '../../docs/stories/**/*.mdx'], + addons: ['@storybook/addon-docs'], framework: '@storybook/react-vite', viteFinal: (config) => { const stub = path.resolve(here, 'tauri-stub.ts'); @@ -35,6 +42,13 @@ const config: StorybookConfig = { // unbuilt `dist`. The directory alias covers the subpath and the bare // specifier both. 'dor-lib-common': path.resolve(here, '..', '..', 'dor-lib-common', 'src'), + // `docs/stories/*.mdx` lives outside this package, so Node resolution + // from that file never reaches `lib/node_modules` and the docs blocks + // fail to resolve. Resolve them here, where the package *is* installed, + // and alias the exact specifier the MDX imports. + '@storybook/addon-docs/blocks': requireFromHere.resolve( + '@storybook/addon-docs/blocks', + ), }; return config; }, diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 26bab40f..2ad9ec7c 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -29,6 +29,10 @@ import { type AlertSpeechState, } from '../src/lib/alert-speech-state'; import { VSCODE_THEMES, VSCODE_THEME_TYPES } from './themes'; +import { + makeStubRemoteHostLink, + type PrimedRemoteHost, +} from '../src/host/remote/test-remote-host-link'; import { cfg } from '../src/cfg'; import type { DormouseTheme } from '../src/lib/themes'; import { clearPersistedShellSelection, seedShellStore } from '../src/lib/shell-store'; @@ -237,6 +241,20 @@ const preview: Preview = { // is how the Settings dialog decides to hide its Shell row. platform.hostOwnsShells = context.parameters?.hostOwnsShells === true || undefined; + // And the same seam again for the Settings dialog's Remote control + // section, which renders nothing without a Host service behind the + // webview (`docs/specs/server.md`). Absent is the honest default for a + // fake platform, so only the stories about that section prime a stub. + // Read during render like the two above: the store reads `remoteHost` + // when the section first subscribes, which is after this decorator's + // render body and before any effect. + const primedRemoteHost = context.parameters?.primedRemoteHost as + | PrimedRemoteHost + | undefined; + platform.remoteHost = primedRemoteHost + ? makeStubRemoteHostLink(primedRemoteHost) + : undefined; + // Installed themes normally arrive from OpenVSX and live in localStorage, // which every story shares — so a story that wants them names them, and // every other story clears them. diff --git a/lib/package.json b/lib/package.json index 97e3cda5..bade298a 100644 --- a/lib/package.json +++ b/lib/package.json @@ -34,6 +34,7 @@ "tailwind-variants": "^3.2.2" }, "devDependencies": { + "@storybook/addon-docs": "^10.4.0", "@storybook/react": "^10.4.0", "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "^4.3.0", diff --git a/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx index 49a8a259..8dc4ed58 100644 --- a/lib/src/components/RemoteControlSection.test.tsx +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -18,6 +18,11 @@ vi.mock('../lib/platform', () => ({ })); import { RemoteControlSection } from './RemoteControlSection'; +import type { RemoteHostConsoleStatus } from '../host/remote/service-protocol'; +import { + enrolledStatus, + UNENROLLED_STATUS as NOT_ENROLLED, +} from '../host/remote/test-remote-host-link'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -41,24 +46,14 @@ function makeLink(command: (cmd: string, params?: unknown) => Promise) }; } -const NOT_ENROLLED = { - enrolled: false, - serverUrl: null, - hostId: null, - connection: 'idle', - pairedClients: 0, -}; - -function enrolled(overrides: Record = {}) { - return { - enrolled: true, +/** The shared fixture, keeping this file's own server/host values. */ +const enrolled = (over: Partial = {}) => + enrolledStatus({ serverUrl: 'https://laptop.tailnet.ts.net', hostId: 'host-1', - connection: 'connected', pairedClients: 1, - ...overrides, - }; -} + ...over, + }); let container: HTMLDivElement; let root: Root; diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index abf35f41..c9413406 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -1,4 +1,4 @@ -import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; +import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo, RemoteHostLink } from './types'; import { AlertManager } from '../alert-manager'; import type { AlertSettings } from '../alert-settings'; import { normalizeExternalUri } from '../external-links'; @@ -55,6 +55,12 @@ export class FakePtyAdapter implements PlatformAdapter { hostOwnsTheme?: boolean; hostOwnsShells?: boolean; + // Same reason, one layer up: a fake platform has no Host service behind it, so + // this stays undefined and the Settings dialog's Remote control section renders + // nothing (`docs/specs/server.md`). The preview decorator installs a stub link + // for the stories that are *about* that section. + remoteHost?: RemoteHostLink; + constructor() { this.alertManager.onStateChange((id, state) => { for (const handler of this.alertStateHandlers) { diff --git a/lib/src/stories/SettingsDialog.stories.tsx b/lib/src/stories/SettingsDialog.stories.tsx index b680f019..bf7f1f30 100644 --- a/lib/src/stories/SettingsDialog.stories.tsx +++ b/lib/src/stories/SettingsDialog.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from 'storybook/test'; import type { DormouseTheme } from '../lib/themes'; import { SettingsDialog } from '../components/SettingsDialog'; +import { enrolledStatus } from '../host/remote/test-remote-host-link'; /** * The app-global Settings dialog, normally opened from the far right of the @@ -259,3 +260,22 @@ export const HostOwnsShells: Story = { primedAlertSettings: {}, }, }; + +/** + * The Remote control section in place — last, and directly under the push + * settings whose `no-host` copy points at it. Every other story here leaves + * `primedRemoteHost` unset, which is a build with no Host service behind the + * webview: the section renders nothing at all rather than offering a form the + * build cannot honor (`docs/specs/server.md`). `RemoteControlSection.stories` + * covers its own states. + */ +export const WithRemoteControl: Story = { + parameters: { + primedRemoteHost: { status: enrolledStatus({ pairedClients: 1 }) }, + primedWatchedCommands: ['claude'], + primedAlertSettings: { pushEnabled: true }, + }, + play: async ({ canvasElement }) => { + await within(canvasElement).findByText('1 paired device.'); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc972100..17388cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: specifier: ^3.2.2 version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) devDependencies: + '@storybook/addon-docs': + specifier: ^10.4.0 + version: 10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) '@storybook/react': specifier: ^10.4.0 version: 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3) @@ -1163,6 +1166,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + '@napi-rs/keyring-darwin-arm64@1.3.0': resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} engines: {node: '>= 10'} @@ -1921,6 +1930,15 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-docs@10.5.9': + resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.9 + peerDependenciesMeta: + '@types/react': + optional: true + '@storybook/builder-vite@10.5.8': resolution: {integrity: sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ==} peerDependencies: @@ -1945,6 +1963,24 @@ packages: webpack: optional: true + '@storybook/csf-plugin@10.5.9': + resolution: {integrity: sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==} + peerDependencies: + esbuild: '*' + rollup: '*' + storybook: ^10.5.9 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -1967,6 +2003,20 @@ packages: '@types/react-dom': optional: true + '@storybook/react-dom-shim@10.5.9': + resolution: {integrity: sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.9 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@storybook/react-vite@10.5.8': resolution: {integrity: sha512-ioMJGi4YzueGsJBlYio+2+UhfCFB9QV5Bs1lOilkek+a4BZgKJl0D1mVSJl6k96stQBPZmLgI9/l0hLVcUL6Kg==} peerDependencies: @@ -2237,6 +2287,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -5247,6 +5300,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@types/mdx': 2.0.14 + '@types/react': 19.2.18 + react: 19.2.8 + '@napi-rs/keyring-darwin-arm64@1.3.0': optional: true @@ -5764,6 +5823,25 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@storybook/addon-docs@10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) + '@storybook/csf-plugin': 10.5.9(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/icons': 2.1.0(react@19.2.8) + '@storybook/react-dom-shim': 10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + ts-dedent: 2.3.0 + optionalDependencies: + '@types/react': 19.2.18 + transitivePeerDependencies: + - '@types/react-dom' + - esbuild + - rollup + - vite + - webpack + '@storybook/builder-vite@10.5.8(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': dependencies: '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) @@ -5784,6 +5862,15 @@ snapshots: rollup: 4.62.2 vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + '@storybook/csf-plugin@10.5.9(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + dependencies: + storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.2 + rollup: 4.62.2 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + '@storybook/global@5.0.0': {} '@storybook/icons@2.1.0(react@19.2.8)': @@ -5799,6 +5886,15 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@storybook/react-dom-shim@10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@storybook/react-vite@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) @@ -6059,6 +6155,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/mdx@2.0.14': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 From 2c6f4badced1ed5dd96142720553bf72924ebe98 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 20:02:49 -0700 Subject: [PATCH 6/8] fix(settings): point "below" at the section that is actually below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describePushTargets` said "Connect this machine to a Dormouse server **below** to send push" for every `no-host`, but `no-host` is a superset of the seam the Remote control section gates on. It covers a Host service that has not enrolled *and* a build with no Host service at all — the website leaves it there forever (`push-devices.ts`) — and in the second case `RemoteControlSection` renders `null`, so the word pointed the reader at nothing. The word now keys on `getPlatform().remoteHost`, the same seam the section itself uses, read beside the two `getPlatform()` reads already in this render and passed in so the copy function stays pure. `PushNoHost` and the new `PushNotEnrolled` are the two cases side by side: same push status, different builds, and only the second has anything below it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB --- lib/src/components/SettingsDialog.tsx | 19 +++++++++++++++--- lib/src/stories/SettingsDialog.stories.tsx | 23 ++++++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index ed7200ae..0a284339 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -43,10 +43,20 @@ const SECTION = 'mt-4 border-t border-border pt-3'; * (`docs/specs/remote-security-model.md`), so there is no account-wide device * list to show and the copy must not imply one. */ -function describePushTargets(push: PushDevicesState): string { +function describePushTargets(push: PushDevicesState, hasHostService: boolean): string { if (push.status === 'loading') return 'Looking for devices…'; if (push.status === 'error') return 'Could not reach the server to list devices.'; - if (push.status === 'no-host') return 'Connect this machine to a Dormouse server below to send push.'; + // `no-host` covers two builds: one whose Host service simply has not enrolled, + // and one with no Host service at all (`push-devices.ts` — the website leaves + // it here forever). Only the first has a Remote control section beneath this + // line, because the second is exactly where that section renders nothing, so + // "below" has to key on the same seam the section gates on rather than on + // `no-host`. + if (push.status === 'no-host') { + return hasHostService + ? 'Connect this machine to a Dormouse server below to send push.' + : 'Connect this machine to a Dormouse server to send push.'; + } if (push.devices.length === 0) { return 'No device paired with this machine has enabled alerts in Dormouse Pocket yet.'; } @@ -87,6 +97,9 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { // to offer. That also covers every host whose adapter detects no shells and // every host that never seeds the store (fake = 1, remote = 0). const showShell = !getPlatform().hostOwnsShells && shellState.shells.length >= 2; + // The same seam `RemoteControlSection` gates on, read here so the push line + // above it cannot promise a section this build does not render. + const hasHostService = getPlatform().remoteHost !== undefined; // A phone can enable alerts long after this machine booted, so re-read the // list on open rather than showing whatever was true at Host start. @@ -183,7 +196,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { onToggle={(pushEnabled) => updateAlertSettings({ pushEnabled })} onCommitDelay={(pushDelayMs) => updateAlertSettings({ pushDelayMs })} > - {describePushTargets(push)} + {describePushTargets(push, hasHostService)} {/* Last, and directly under the push section that points at it: push is diff --git a/lib/src/stories/SettingsDialog.stories.tsx b/lib/src/stories/SettingsDialog.stories.tsx index bf7f1f30..5161517b 100644 --- a/lib/src/stories/SettingsDialog.stories.tsx +++ b/lib/src/stories/SettingsDialog.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from 'storybook/test'; import type { DormouseTheme } from '../lib/themes'; import { SettingsDialog } from '../components/SettingsDialog'; -import { enrolledStatus } from '../host/remote/test-remote-host-link'; +import { enrolledStatus, UNENROLLED_STATUS } from '../host/remote/test-remote-host-link'; /** * The app-global Settings dialog, normally opened from the far right of the @@ -100,7 +100,8 @@ export const PushNoDevices: Story = { }, }; -/** No remote Host at all — the ordinary case for a machine that never enrolled. */ +/** No Host service in this build at all — the website. Nothing renders below, + * so the copy must not point there. Paired with `PushNotEnrolled`. */ export const PushNoHost: Story = { parameters: { primedWatchedCommands: ['claude'], @@ -109,6 +110,24 @@ export const PushNoHost: Story = { }, }; +/** + * The other `no-host`: a build that *does* have a Host service, which simply has + * not enrolled. Same push status as `PushNoHost`, but here the Remote control + * section renders beneath — so this is the one whose copy may say "below", and + * the pair is what keeps that word honest. + */ +export const PushNotEnrolled: Story = { + parameters: { + primedWatchedCommands: ['claude'], + primedAlertSettings: { pushEnabled: true }, + primedPushDevices: { status: 'no-host', devices: [] }, + primedRemoteHost: { status: UNENROLLED_STATUS }, + }, + play: async ({ canvasElement }) => { + await within(canvasElement).findByText(/server below to send push/); + }, +}; + /** * Non-default timings, proving every number field renders the stored value * rather than a hardcoded one. From 321ad1df4ca3177534cd376092d2c3119ccd2c8b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 20:03:00 -0700 Subject: [PATCH 7/8] perf(remote): stop republishing an unchanged status every poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store polls every 2 s while the dialog is open and the service answers with a fresh object each time, so `setState` notified on every tick and `useSyncExternalStore` re-rendered the section twice a minute to paint identical text. Compared field-wise before storing, which is the whole of it: the five members of `RemoteHostConsoleStatus` are primitives, and `loading` / `unsupported` are singletons where matching kinds is the answer. This is what the sibling store the same dialog reads already does — `setPushDevices` is commented "Identity-guarded so a repeat write does not churn React" — and the two now read as the matched pair they otherwise looked like. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB --- docs/specs/server.md | 18 ++++++-- lib/src/remote/host/host-status-store.test.ts | 44 +++++++++++++++++++ lib/src/remote/host/host-status-store.ts | 28 ++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 4d5831b3..96b05ca0 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -546,9 +546,16 @@ of truth: `lib/src/components/RemoteControlSection.tsx` over It renders **nothing at all** where `getPlatform().remoteHost` is absent — the website and the lib dev server have no Host service behind them, and offering -the form would promise something the build cannot do. That is the same seam the -push-devices line keys on, which is why its `no-host` copy can point at this -section. +the form would promise something the build cannot do. + +The push-devices line above it must key on that same seam, and **not** on its +own `no-host`, which is a superset: `no-host` covers both a Host service that +has not enrolled *and* a build with no Host service at all +([alert.md](./alert.md) -> Push notifications). Only the first has a section +beneath it, so only the first says "below" — otherwise the website points the +reader at nothing. Source of truth: `describePushTargets` in +`lib/src/components/SettingsDialog.tsx`, which takes the seam as an argument; +the `PushNoHost` / `PushNotEnrolled` story pair holds the two apart. Un-enrolled it is a three-field form (server, setup password, name for this machine) calling the service's `enroll`; enrolled it shows the server URL, the @@ -577,6 +584,11 @@ only on `displaced` — `Reconnect`. Rules the UI exists to honor: timeout is allowed to become the visible error instead of being superseded by newer polls. Without the poll a machine that finished connecting a moment after the dialog opened would read as permanently "Connecting…". +- **A repeat answer is not published.** The service returns a fresh object every + poll, so the state is compared field-wise before it is stored — otherwise the + section would re-render twice a minute to paint identical text. This matches + the sibling store the same dialog reads (`setPushDevices` in + `lib/src/lib/push-devices.ts`). - **Coalescing stops at anything that changes the answer.** `enroll`, `reconnect` and `clearEnrollment` each drop the read in flight and start their own, because a `status` issued before the command answers the question as it diff --git a/lib/src/remote/host/host-status-store.test.ts b/lib/src/remote/host/host-status-store.test.ts index 5bc6cf9b..aa62678e 100644 --- a/lib/src/remote/host/host-status-store.test.ts +++ b/lib/src/remote/host/host-status-store.test.ts @@ -178,3 +178,47 @@ describe('re-subscribing', () => { }); }); +describe('publishing', () => { + /** + * The service answers with a fresh object every poll, so an unguarded write + * would re-render the section twice a minute to paint identical text. The + * sibling store this same dialog reads guards the same way (`setPushDevices`). + */ + it('does not notify when a poll answers the same status again', async () => { + vi.useFakeTimers(); + const status = { + enrolled: true, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + }; + // A new object each time, exactly as a round trip through the service gives. + const command = vi.fn(async () => ({ ...status })); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + const listener = vi.fn(); + const unsubscribe = subscribeToRemoteHostStatus(listener); + try { + await vi.advanceTimersByTimeAsync(0); + expect(listener).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3 * 2000); + expect(command.mock.calls.length).toBeGreaterThan(1); + expect(listener).toHaveBeenCalledTimes(1); + + // A real change still publishes. + status.pairedClients = 2; + await vi.advanceTimersByTimeAsync(2000); + expect(listener).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + } + }); +}); + diff --git a/lib/src/remote/host/host-status-store.ts b/lib/src/remote/host/host-status-store.ts index df7439a8..3be3556c 100644 --- a/lib/src/remote/host/host-status-store.ts +++ b/lib/src/remote/host/host-status-store.ts @@ -60,11 +60,39 @@ const POLL_MS = 2000; /** Invalidates an in-flight answer when the last subscriber goes away. */ let generation = 0; +/** + * Publish a new state, skipping a write that says the same thing. + * + * The poll re-reads every 2 s and the service answers with a fresh object each + * time, so without this the section re-renders twice a minute to paint + * identical text. The sibling store this same dialog reads guards the same way + * (`setPushDevices` in `lib/src/lib/push-devices.ts`); comparing the five + * primitives is the whole of it, because `RemoteHostConsoleStatus` has no + * nested value. + */ function setState(next: RemoteHostStatusState): void { + if (sameState(state, next)) return; state = next; for (const listener of listeners) listener(); } +function sameState(a: RemoteHostStatusState, b: RemoteHostStatusState): boolean { + if (a === b) return true; + if (a.kind !== b.kind) return false; + if (a.kind === 'error' && b.kind === 'error') return a.message === b.message; + if (a.kind === 'ready' && b.kind === 'ready') { + return ( + a.status.enrolled === b.status.enrolled && + a.status.serverUrl === b.status.serverUrl && + a.status.hostId === b.status.hostId && + a.status.connection === b.status.connection && + a.status.pairedClients === b.status.pairedClients + ); + } + // `unsupported` and `loading` are the two singletons, so matching kinds is all. + return true; +} + /** * `getPlatform` throws before `initPlatform`, and a host may simply have no * service. Both mean the same thing here: nothing to ask. From f0d205ea64a99b8807c6ff83a76a2ab3e9260a7d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Aug 2026 20:06:01 -0700 Subject: [PATCH 8/8] fix(storybook): drop a Chromatic opt-out that never applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storybook 10's `Meta` doc block is `({ of }) => …` — it destructures nothing else, so the `parameters` prop compiled (its type is `BaseAnnotations & { of?, title? }`) and was then never read. `parameters` on `` was CSF-in-MDX, removed in Storybook 7; the examples still showing it, Chromatic's own included, predate that. The dead prop cost nothing. The comment above it did: it told the next reader this page cannot be the source of a Chromatic diff, which would have sent them looking anywhere else first. Both are gone rather than replaced, because an unattached docs page has no per-entry opt-out to replace them with — attaching it to the `RemoteControlSection` meta would inherit the parameter but also disable snapshots for the eight states that meta exists to cover. Whether Chromatic captures docs entries here is still open. The story counts lean toward no — CI published "234 stories" for a build whose index held 234 stories and one docs entry — and the next build's changes list settles it. Also widens the `generation` comment: the mutation path drops an in-flight read for the same reason unsubscribe does, so scoping it to unsubscribe read as though enroll and disconnect no longer invalidated one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB --- docs/stories/pairing.mdx | 8 +------- lib/src/remote/host/host-status-store.ts | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx index a844f41b..d0fef331 100644 --- a/docs/stories/pairing.mdx +++ b/docs/stories/pairing.mdx @@ -6,13 +6,7 @@ import * as SetupOrSignin from '../../lib/src/stories/SetupOrSignin.stories'; import * as HostsView from '../../lib/src/stories/HostsView.stories'; import * as PocketWall from '../../lib/src/stories/PocketWall.stories'; -{/* Chromatic already snapshots each of the 13 embedded stories on its own, and - this is the only entry whose capture races six lazy iframes — so a diff here - would be a loader caught mid-boot, not a design change. */} - + # Pairing a phone with your laptop diff --git a/lib/src/remote/host/host-status-store.ts b/lib/src/remote/host/host-status-store.ts index 3be3556c..2696d64c 100644 --- a/lib/src/remote/host/host-status-store.ts +++ b/lib/src/remote/host/host-status-store.ts @@ -57,7 +57,7 @@ let refreshAgain = false; */ const POLL_MS = 2000; -/** Invalidates an in-flight answer when the last subscriber goes away. */ +/** Invalidates an in-flight answer that can no longer be the one anybody wants. */ let generation = 0; /**