diff --git a/CHANGELOG.md b/CHANGELOG.md index 248a8e2aa8..5ccb924217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Added +- **Shared settings and pins.** The browsers of one server, a PC's and a phone's, keep the same + pinned agents and the same settings that do not depend on the device: theme and colors, language, + the sidebar's rows, order, cards and pins, chat and usage-meter choices. A + change shows on the other open pages at once. Each device keeps its own terminal and chat font + sizes, key bar, shortcuts, density, alerts, microphone, the lens panes open in and the like. Opening a page never + overwrites anything: a server that holds no shared settings yet takes them from the first change, + and the browser that shares first brings its pins along. A page that lost its connection catches up + when it is back. A watching device follows the shared settings and cannot change them for everyone. ([#854](https://github.com/devswha/herdr-web-ui/pull/854)) + ## [0.4.8] - 2026-10-12 ### Added diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index 6e8bf4c6da..88ac70f47d 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -91,5 +91,6 @@ run_script scripts/narrow-pane-demo-regression.ts run_script scripts/touch-long-press-demo-regression.ts run_script scripts/phone-split-grid-demo-regression.ts run_script scripts/recent-folders-regression.ts +run_script scripts/preferences-sync-regression.ts run_script scripts/machine-dialog-regression.ts run_script scripts/machine-conflict-regression.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 4dfed710fa..1f50ad94c1 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -59,7 +59,7 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(38); + expect(rows(text)).toHaveLength(39); expect(rows(text).map((row) => row.script)).toContain("scripts/agy-chat-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts"); diff --git a/scripts/preferences-sync-regression.ts b/scripts/preferences-sync-regression.ts new file mode 100644 index 0000000000..6b8672180d --- /dev/null +++ b/scripts/preferences-sync-regression.ts @@ -0,0 +1,130 @@ +/** + * Two browsers of one server keep the shared settings and pins the same (lib/preferencesSync.ts): + * a theme changed in one shows in the other at once, the other's terminal font (this device's) + * does not move and is never sent, the first browser to share carries its pins along and pins + * written elsewhere reach an open page, a change from elsewhere that lands while one of this + * browser's is on its way is kept, a page whose GET fails still catches up from the event stream, + * and opening a page on an empty server sends nothing. No herdr session is opened. + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type BrowserContext, type Page } from "playwright-core"; + +await import("./test-herdr.ts"); +const { createServer } = await import("../server/index.ts"); +const { UsageService } = await import("../server/usage.ts"); + +const root = mkdtempSync(join(tmpdir(), "herdr-preferences-sync-")); +const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), usage: new UsageService(undefined, []) }); +const origin = `http://127.0.0.1:${server.port}`; + +async function until(check: () => Promise, label: string): Promise { + const deadline = Date.now() + 10_000; + while (!(await check())) { + assert.ok(Date.now() < deadline, label); + await Bun.sleep(50); + } +} + +interface Opened { context: BrowserContext; page: Page; puts: { settings: Record; pins?: string[] }[] } + +/** a browser of its own with `own` in its settings and `pins` pinned; `puts` collects what it sends to /api/preferences */ +async function browserAt(browser: Awaited>, own: Record = {}, pins: string[] = [], before?: (context: BrowserContext) => Promise): Promise { + const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, locale: "en-US" }); + await context.addInitScript(([own, pins]) => { + if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme: "dark", terminalFontSize: 13, ...own })); + if (!localStorage.getItem("herdr-web-ui:sidebar-agent-pins")) localStorage.setItem("herdr-web-ui:sidebar-agent-pins", JSON.stringify(pins)); + }, [own, pins] as const); + await before?.(context); + const puts: Opened["puts"] = []; + const page = await context.newPage(); + page.on("request", (request) => { if (request.method() === "PUT" && request.url().endsWith("/api/preferences")) puts.push(request.postDataJSON()); }); + await page.goto(origin); + await page.locator(".conn-live").waitFor({ state: "attached" }); + return { context, page, puts }; +} + +const theme = (page: Page) => page.evaluate(() => document.documentElement.dataset["theme"]); +const stored = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:settings") ?? "{}") as Record); +const storedPins = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:sidebar-agent-pins") ?? "[]") as string[]); +const held = async () => await (await fetch(`${origin}/api/preferences`)).json() as { revision: number; settings: Record; pins?: string[] }; +const write = (page: Page, body: object) => page.evaluate(async (body) => (await fetch("/api/preferences", { method: "PUT", headers: { "content-type": "application/json" }, body })).status, JSON.stringify(body)); + +try { + const browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + try { + // A has pinned an agent before this release; B has not. The pane need not exist here: a pin of a + // pane this page cannot see stays in the list (a PC still loading would look the same) + const a = await browserAt(browser, {}, ["local:w1:pa"]); + const b = await browserAt(browser); + await Bun.sleep(1_500); + assert.deepEqual([...a.puts, ...b.puts], [], "opening a page on an empty server sends nothing"); + assert.deepEqual(await held(), { revision: 0, settings: {} }, "the server holds nothing, pins included"); + + // A turns light: B follows while open, only the shared setting went out, and A's pins went with + // the first change since the server held none + await a.page.goto(`${origin}/?settings=appearance`); + await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Light", exact: true }).click(); + await until(async () => await theme(b.page) === "light", "B takes A's theme without a reload"); + assert.deepEqual(a.puts.map((body) => Object.keys(body.settings)), [["theme"]], "only the setting that changed is sent"); + assert.deepEqual(a.puts[0]?.pins, ["local:w1:pa"], "the first change carries this browser's pins"); + await until(async () => JSON.stringify(await storedPins(b.page)) === JSON.stringify(["local:w1:pa"]), "B takes A's pins"); + + // A's terminal font is A's own: B keeps 13 + await a.page.goto(`${origin}/?settings=terminal`); + await a.page.getByRole("button", { name: "Increase terminal font size", exact: true }).click(); + await until(async () => (await stored(a.page))["terminalFontSize"] === 14, "A keeps its new font"); + await Bun.sleep(1_500); + assert.equal(a.puts.length, 1, "a device setting is never sent"); + assert.equal((await stored(b.page))["terminalFontSize"], 13, "B's terminal font does not move"); + + // pins written by another browser reach an open page, and stay (the last write wins now) + assert.equal(await write(b.page, { settings: {}, pins: ["local:w9:p1"] }), 200); + await until(async () => JSON.stringify(await storedPins(a.page)) === JSON.stringify(["local:w9:p1"]), "A takes the pins written elsewhere"); + + // a change from elsewhere that lands while A's own is on its way is kept, and A's still arrives + await a.page.route("**/api/preferences", async (route) => { + if (route.request().method() === "PUT") await Bun.sleep(1_500); + await route.continue(); + }); + await a.page.goto(`${origin}/?settings=appearance`); + await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Dark", exact: true }).click(); + await Bun.sleep(900); + assert.equal(a.puts.length, 2, "A's theme is on its way"); + assert.equal(await write(b.page, { settings: { palette: "forest" } }), 200); + await until(async () => (await stored(a.page))["palette"] === "forest", "A takes B's palette while its own change is in flight"); + await until(async () => { const now = await held(); return now.settings["theme"] === "dark" && now.settings["palette"] === "forest"; }, "the server holds both changes"); + assert.equal((await stored(a.page))["theme"], "dark", "A keeps its own change"); + await until(async () => await theme(b.page) === "dark", "B takes A's theme"); + await Bun.sleep(1_500); + assert.equal(a.puts.length, 2, "A sends nothing back for what it took"); + await a.page.unrouteAll({ behavior: "wait" }); + + // a page opened later starts from what the server holds, and keeps a shared setting nobody changed + const c = await browserAt(browser, { showThinking: false }); + await until(async () => await theme(c.page) === "dark" && (await stored(c.page))["palette"] === "forest", "a new page takes the shared theme and palette"); + assert.equal((await stored(c.page))["terminalFontSize"], 13, "and keeps its own terminal font"); + assert.equal((await stored(c.page))["showThinking"], false, "and a shared setting nobody changed stays its own"); + assert.deepEqual(await storedPins(c.page), ["local:w9:p1"], "and takes the shared pins"); + + // a page whose GET fails catches up from the event stream, which opens with the document + const d = await browserAt(browser, {}, [], async (context) => { + await context.route("**/api/preferences", async (route) => { + if (route.request().method() === "GET") await route.abort(); + else await route.continue(); + }); + }); + await until(async () => (await stored(d.page))["palette"] === "forest", "a page without the GET takes the document from the event stream"); + await Bun.sleep(1_500); + assert.deepEqual([...c.puts, ...d.puts], [], "pages opened later send nothing"); + for (const opened of [a, b, c, d]) await opened.context.close(); + console.log("PASS shared settings and pins follow across browsers, device settings stay, concurrent changes both land, an empty server is never written on load"); + } finally { + await browser.close(); + } +} finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); +} diff --git a/server/AGENTS.md b/server/AGENTS.md index bd7d619e7c..ecb991271d 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -82,6 +82,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the | What an update brings | release-notes.ts | CHANGELOG.md sections plus `release-summaries.json`, newest first and bounded; `## [Unreleased]` is no release, and another level-two heading belongs to the notes it sits in | | Agent stores beyond claude/codex/omo | gjc-runtime.ts, pi.ts, claude-store.ts, omp.ts (transcript-records.ts and skill-activity.ts parse what they find) | each store lookup answers null rather than guessing which transcript a pane writes. pi's store moves with `PI_CODING_AGENT_SESSION_DIR` or `PI_CODING_AGENT_DIR`, and a session file is not written until the first answer, so an absent path can still be the pane's. gjc on Windows is found through the PC's process table, shared between polls for PROCESS_TABLE_MS; an empty table is a failed read, not an answer | | Paired devices | devices.ts + /api/devices* in index.ts | six-digit code valid ten minutes, one per pairing; the device's token is stored only as a hash in devices.json (0600, written whole), and revoking also closes that device's live streams | +| Shared preferences | preferences.ts + /api/preferences in index.ts; shared/preferences.ts | the settings every browser of this server shares (`SHARED_SETTING_KEYS`) and the sidebar pins, one document in preferences.json (0600, written whole) with a `revision` counter; a PUT carries the settings that changed (laid over the held ones, last write wins per setting) and the pins when they changed, and keeps only the shared names, at most 500 pins and 64 KB; a watching device reads it and is refused a PUT by the general read-only gate; each write goes to every open browser as a `preferences` event on /api/machines/events | - Transcript caches are keyed by file, so a pane's chat is remembered against what it read and `forgetPaneTranscriptState(paneId)` (conversation.ts) drops it from every reader (pi-tree, codex, claude-store, gjc-runtime, opencode) when the pane closes or ends. `forgetTranscriptState()` is the test-suite wipe and clears the same state plus the pane index. Any new transcript cache joins one of those two. - The shell and every static file carry the app's Content-Security-Policy from `serveStatic` (static.ts); `/api/fs/*` answers carry file-view.ts's own stricter one: scripts and connections are `'self'` only, styles carry `'unsafe-inline'` for the measured layout attributes and KaTeX. `HERDR_WEB_CSP=report-only` measures it without enforcing. A new third-party resource needs a directive there and a reason in the comment. diff --git a/server/device-access.contract.test.ts b/server/device-access.contract.test.ts index 21a734a9f2..aa7a3f9fe3 100644 --- a/server/device-access.contract.test.ts +++ b/server/device-access.contract.test.ts @@ -120,6 +120,9 @@ it("watch credentials cannot mutate HTTP state, read credential files, or elevat for (const path of ["pane/input", "workspace/create", "tab/create", "tab/rename", "tab/close", "machines/setup", "updates/install", "herdr/update"]) { expect((await fetch(`${base}/api/${path}`, { method: "POST", headers, body: "{}" })).status).toBe(403); } + // the shared settings: a watching device reads them, and cannot change them for every browser + expect((await fetch(`${base}/api/preferences`, { headers })).status).toBe(200); + expect((await fetch(`${base}/api/preferences`, { method: "PUT", headers, body: JSON.stringify({ settings: { theme: "light" }, pins: [] }) })).status).toBe(403); expect((await fetch(`${base}/api/fs/file?path=${encodeURIComponent(join(root, "devices.json"))}`, { headers })).status).toBe(403); // a PC's file is read without the origin check (#448, Android's Open button): the watch refusal still comes first, for GET and HEAD alike for (const method of ["GET", "HEAD"]) { diff --git a/server/index.ts b/server/index.ts index e8ae2d52b7..8a435a15cd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { DEFAULT_PORT } from "../shared/protocol.ts"; import { DEVICE_COOKIE, authClient, handleAuthRequest, isAuthenticated, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isServeOwnerRequest } from "./access.ts"; import { DeviceStore, handleDeviceRequest } from "./devices.ts"; +import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts"; import { remoteAccess, TailnetIdentitySource } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; import { paneFiles } from "./files.ts"; @@ -525,6 +526,7 @@ export function createServer( const token = options.token ?? process.env["HERDR_WEB_TOKEN"] ?? ""; /** paired devices (server/devices.ts) and the PC's Tailscale login: the two ways in besides the token and this PC itself */ const devices = new DeviceStore(options.stateDir ?? defaultStateDir()); + const preferences = new PreferenceStore(options.stateDir ?? defaultStateDir()); const usage = options.usage ?? new UsageService(); const voice = options.voice ?? new VoiceService({ stateDir: options.stateDir ?? defaultStateDir(), env: process.env, fetch }); /** a login named here is taken as it is: a tagged node has none of its own to read (HERDR_WEB_TAILSCALE_OWNER) */ @@ -1698,7 +1700,7 @@ export function createServer( // a worktree made with an agent waits on git and then agent.start, up to 150 s on the PC bunServer.timeout(request, pathname === "/api/machines/events" ? 0 : pathname.endsWith("/worktree/create") ? 180 : 80); const deviceId = access.level === "full" ? access.device?.id : undefined; - const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : undefined); + const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : undefined, preferences); response.headers.set("cache-control", "no-store"); return response; } @@ -1742,6 +1744,8 @@ export function createServer( } if (pathname === "/api/herdr/update") return handleHerdrUpdateRequest(request, options.herdrUpdate); if (pathname === "/api/telemetry") return handleTelemetryRequest(request, options.telemetry); + // what every browser of this server shares; a watching device reads it and cannot change it (above) + if (pathname === "/api/preferences") return handlePreferencesRequest(request, preferences); if (pathname === "/api/usage") return handleUsageRequest(request, url, usage); // a long clip can keep the provider silent past Bun's 10 s idle limit before the first line diff --git a/server/machine-api.ts b/server/machine-api.ts index 473f69c87d..66144135af 100644 --- a/server/machine-api.ts +++ b/server/machine-api.ts @@ -1,4 +1,5 @@ -import type { SetupAction, SetupRequest } from "../shared/machines.ts"; +import type { MachineEvent, SetupAction, SetupRequest } from "../shared/machines.ts"; +import type { SharedPreferences } from "../shared/preferences.ts"; import { MachineManager } from "./machines.ts"; import { canSendSecret, sameOrigin } from "./machine-security.ts"; import { isJsonObject, jsonResponse } from "./http.ts"; @@ -21,7 +22,8 @@ function transportFailure(id: string, path: string, error: unknown): Response { : fail("machine_unavailable", "The PC connection was interrupted; retry after reconnecting", 502); } -export async function handleMachineRequest(request: Request, manager: MachineManager, onRevoke?: (close: () => void) => () => void): Promise { +export async function handleMachineRequest(request: Request, manager: MachineManager, onRevoke?: (close: () => void) => () => void, + preferences?: { get(): SharedPreferences; subscribe(listener: (preferences: SharedPreferences) => void): () => void }): Promise { const url = new URL(request.url); // A PC's file opens as this PC's /api/fs/file does, from any navigation: reading it changes // nothing, and Chrome on Android shows a PDF in the viewer's frame as an "Open" button whose @@ -36,11 +38,12 @@ export async function handleMachineRequest(request: Request, manager: MachineMan if (!parts.length && request.method === "GET") return jsonResponse({ machines: manager.list() }); if (parts[0] === "events" && parts.length === 1 && request.method === "GET") { let stop: (() => void) | undefined; + let stopPreferences: (() => void) | undefined; let unwatchDevice: (() => void) | undefined; let heartbeat: ReturnType | undefined; let close = () => {}; const cleanup = () => { - stop?.(); unwatchDevice?.(); clearInterval(heartbeat); + stop?.(); stopPreferences?.(); unwatchDevice?.(); clearInterval(heartbeat); request.signal.removeEventListener("abort", close); }; const encoder = new TextEncoder(); @@ -52,6 +55,11 @@ export async function handleMachineRequest(request: Request, manager: MachineMan if (closed) return; const send = (text: string) => { if (closed) return; if ((controller.desiredSize ?? 0) < -4) { close(); return; } try { controller.enqueue(encoder.encode(text)); } catch { close(); } }; stop = manager.subscribe((event) => send(`data: ${JSON.stringify(event)}\n\n`)); + // the shared preferences ride the same stream, one connection per browser; the document + // opens it, so a page that reconnects (or whose GET lost a race) holds the latest + const sendPreferences = (shared: SharedPreferences) => send(`data: ${JSON.stringify({ type: "preferences", preferences: shared } satisfies MachineEvent)}\n\n`); + stopPreferences = preferences?.subscribe(sendPreferences); + if (preferences) sendPreferences(preferences.get()); if (closed) { cleanup(); return; } heartbeat = setInterval(() => send(": heartbeat\n\n"), 15_000); request.signal.addEventListener("abort", close, { once: true }); diff --git a/server/preferences.test.ts b/server/preferences.test.ts new file mode 100644 index 0000000000..1a56c3e28a --- /dev/null +++ b/server/preferences.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAX_PINS } from "../shared/preferences.ts"; +import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts"; + +const roots: string[] = []; +const root = (): string => { const dir = mkdtempSync(join(tmpdir(), "herdr-preferences-")); roots.push(dir); return dir; }; +afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +const put = (store: PreferenceStore, body: string, headers: Record = {}) => handlePreferencesRequest(new Request("http://x/api/preferences", { method: "PUT", body, headers }), store); + +describe("shared preferences", () => { + it("starts empty, keeps only the shared names, counts each write, and survives a restart", async () => { + const dir = root(); + const store = new PreferenceStore(dir); + // no pins until a browser shares its own: an empty list would be taken for everyone's + expect(await (await handlePreferencesRequest(new Request("http://x/api/preferences"), store)).json()).toEqual({ revision: 0, settings: {} }); + const heard: number[] = []; + store.subscribe((preferences) => heard.push(preferences.revision)); + const settingsOnly = await put(store, JSON.stringify({ settings: { showThinking: false } })); + expect(await settingsOnly.json()).toEqual({ revision: 1, settings: { showThinking: false } }); + const first = await put(store, JSON.stringify({ settings: { theme: "light", terminalFontSize: 22, alertsOn: false, nonsense: 1 }, pins: ["local:w1:p1", "local:w1:p1", "pc:w2:p3"] })); + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ revision: 2, settings: { showThinking: false, theme: "light" }, pins: ["local:w1:p1", "pc:w2:p3"] }); + // a change lays its settings over what is held, and leaves the pins when it names none + await put(store, JSON.stringify({ settings: { agentPins: true } })); + expect(store.get()).toEqual({ revision: 3, settings: { showThinking: false, theme: "light", agentPins: true }, pins: ["local:w1:p1", "pc:w2:p3"] }); + await put(store, JSON.stringify({ settings: { theme: "dark" }, pins: [] })); + expect(heard).toEqual([1, 2, 3, 4]); + expect(JSON.parse(readFileSync(join(dir, "preferences.json"), "utf8"))).toMatchObject({ revision: 4, settings: { showThinking: false, theme: "dark", agentPins: true }, pins: [] }); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 4, settings: { showThinking: false, theme: "dark", agentPins: true }, pins: [] }); + }); + + it("refuses what is not a preferences document", async () => { + const store = new PreferenceStore(root()); + expect((await put(store, "{")).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: [], pins: [] }))).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: {}, pins: [1] }))).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: {}, pins: Array.from({ length: MAX_PINS + 1 }, (_, i) => `pc:${i}`) }))).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: { quickReplies: ["x".repeat(70_000)] }, pins: [] }))).status).toBe(413); + // a body that says it is too large is refused unread + expect((await put(store, "{}", { "content-length": String(70_000) })).status).toBe(413); + expect((await handlePreferencesRequest(new Request("http://x/api/preferences", { method: "POST", body: "{}" }), store)).status).toBe(405); + expect(store.get().revision).toBe(0); + }); + + it("keeps the whole document under the limit, not only each change", async () => { + const store = new PreferenceStore(root()); + expect((await put(store, JSON.stringify({ settings: { quickReplies: ["x".repeat(40_000)] } }))).status).toBe(200); + const over = await put(store, JSON.stringify({ settings: { usageOrder: ["y".repeat(40_000)] } })); + expect(over.status).toBe(413); + expect(store.get()).toMatchObject({ revision: 1 }); + expect(store.get().settings["usageOrder"]).toBeUndefined(); + }); + + it("holds and tells a change only once it is on disk", async () => { + const dir = root(); + const file = join(dir, "state"); + writeFileSync(file, "a file where the state directory should be"); + const store = new PreferenceStore(file); + const heard: number[] = []; + store.subscribe((preferences) => heard.push(preferences.revision)); + const failed = await put(store, JSON.stringify({ settings: { theme: "light" } })); + expect(failed.status).toBe(500); + expect(await (await handlePreferencesRequest(new Request("http://x/api/preferences"), store)).json()).toEqual({ revision: 0, settings: {} }); + expect(heard).toEqual([]); + }); + + it("starts empty over a damaged file", () => { + const dir = root(); + writeFileSync(join(dir, "preferences.json"), "not json"); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 0, settings: {} }); + }); +}); diff --git a/server/preferences.ts b/server/preferences.ts new file mode 100644 index 0000000000..43e1e76dd2 --- /dev/null +++ b/server/preferences.ts @@ -0,0 +1,79 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { MAX_PREFERENCES_BYTES, sharedPreferencesBody, type PreferencesChange, type SharedPreferences } from "../shared/preferences.ts"; +import { badRequest, isJsonObject, jsonResponse } from "./http.ts"; + +const byteLength = (text: string): number => new TextEncoder().encode(text).length; + +/** The document a change would make is over MAX_PREFERENCES_BYTES; nothing was kept. */ +export class PreferencesTooLarge extends Error { + constructor() { super(`Shared preferences are at most ${MAX_PREFERENCES_BYTES / 1024} KB`); } +} + +/** + * The preferences every browser of this server shares (shared/preferences.ts), kept in the state + * directory beside devices.json. One document: a browser sends the settings it changed (and the + * pins when they changed), laid over what is held, last write wins per setting; the others take + * the whole document from the event. A change is held, and told, only once it is on disk. + */ +export class PreferenceStore { + private current: SharedPreferences = { revision: 0, settings: {} }; + private readonly path: string; + private readonly listeners = new Set<(preferences: SharedPreferences) => void>(); + + constructor(stateDir: string) { + this.path = join(stateDir, "preferences.json"); + try { + const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8")); + const body = sharedPreferencesBody(parsed); + const revision = isJsonObject(parsed) && Number.isSafeInteger(parsed["revision"]) ? parsed["revision"] as number : 0; + if (body !== null) this.current = { revision: Math.max(0, revision), settings: body.settings, ...(body.pins === undefined ? {} : { pins: body.pins }) }; + } catch (error) { + // a missing file is a server that has shared nothing yet; a damaged one is replaced by the next write + if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Cannot read ${this.path}; shared preferences start empty`); + } + } + + get(): SharedPreferences { return this.current; } + + set(change: PreferencesChange): SharedPreferences { + const pins = change.pins ?? this.current.pins; + const next: SharedPreferences = { revision: this.current.revision + 1, settings: { ...this.current.settings, ...change.settings }, ...(pins === undefined ? {} : { pins }) }; + const text = `${JSON.stringify(next, null, 2)}\n`; + if (byteLength(JSON.stringify(next)) > MAX_PREFERENCES_BYTES) throw new PreferencesTooLarge(); + mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); + const temporary = `${this.path}.${process.pid}.tmp`; + writeFileSync(temporary, text, { mode: 0o600 }); + renameSync(temporary, this.path); + this.current = next; + for (const listener of [...this.listeners]) listener(this.current); + return this.current; + } + + subscribe(listener: (preferences: SharedPreferences) => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } +} + +/** /api/preferences: GET the shared document, PUT a change to it. A watching device's PUT is refused before this (index.ts). */ +export async function handlePreferencesRequest(request: Request, store: PreferenceStore): Promise { + const reply = (body: unknown, status = 200) => jsonResponse(body, status, { "cache-control": "no-store" }); + const tooLarge = () => reply({ error: { code: "too_large", message: new PreferencesTooLarge().message } }, 413); + if (request.method === "GET") return reply(store.get()); + if (request.method !== "PUT") return reply({ error: { code: "method_not_allowed", message: "Use GET or PUT /api/preferences" } }, 405); + // a body announced over the limit is refused unread; one that lies about its length is measured + const announced = Number(request.headers.get("content-length")); + if (Number.isFinite(announced) && announced > MAX_PREFERENCES_BYTES) return tooLarge(); + const text = await request.text(); + if (byteLength(text) > MAX_PREFERENCES_BYTES) return tooLarge(); + let raw: unknown; + try { raw = JSON.parse(text); } catch { return badRequest("invalid_json", "The body is not JSON"); } + const body = sharedPreferencesBody(raw); + if (body === null) return badRequest("invalid_body", "Send { settings: object, pins?: string[] }"); + try { return reply(store.set(body)); } + catch (error) { + if (error instanceof PreferencesTooLarge) return tooLarge(); + return reply({ error: { code: "preferences_unwritable", message: error instanceof Error ? error.message : String(error) } }, 500); + } +} diff --git a/shared/machines.ts b/shared/machines.ts index 4448753474..bfed5ff3cd 100644 --- a/shared/machines.ts +++ b/shared/machines.ts @@ -1,3 +1,4 @@ +import type { SharedPreferences } from "./preferences.ts"; import type { HealthAuth, ServerMessage, SessionSnapshot } from "./protocol.ts"; export const LOCAL_MACHINE = "local"; @@ -76,7 +77,9 @@ export interface SetupJob { progress?: SetupProgress | null; } export type SetupAction = { action: "answer"; challenge_id: string; answer: string } | { action: "approve" } | { action: "cancel" }; -export type MachineEvent = { type: "machines"; machines: Machine[] } | { type: "machine-message"; machine_id: string; message: ServerMessage }; +/** `preferences`: what every browser of this server shares changed (shared/preferences.ts) */ +export type MachineEvent = { type: "machines"; machines: Machine[] } | { type: "machine-message"; machine_id: string; message: ServerMessage } + | { type: "preferences"; preferences: SharedPreferences }; export interface BridgeHealth { ok: true; auth: HealthAuth; bridge_protocol: number } export interface BridgeIdentity { pid: number; diff --git a/shared/preferences.ts b/shared/preferences.ts new file mode 100644 index 0000000000..e21f801096 --- /dev/null +++ b/shared/preferences.ts @@ -0,0 +1,44 @@ +/** + * What every browser of this server shares: the settings that do not depend on the device + * (src/lib/settings.ts SETTING_SCOPE says which) and the pinned agents. GET/PUT + * /api/preferences; a change reaches the other browsers as a `preferences` event on + * /api/machines/events. The values are checked where they are used (sanitizeSettings): the + * server keeps only the names below and the shape. A browser sends only the settings it changed, + * which the server lays over what it holds, so a setting nobody changed stays each device's own. + */ +export const SHARED_SETTING_KEYS = [ + "theme", "palette", "language", "sidebarRows", "agentOrder", "agentCards", "agentPins", "autoSendQueued", + "showThinking", "quickReplies", "showQuickReplies", "showSuggestionChip", "showUsage", "usageCount", "usagePace", + "usageGlance", "usageOrder", "usageHidden", "highlightCode", +] as const; +export type SharedSettingKey = (typeof SHARED_SETTING_KEYS)[number]; + +export interface SharedPreferences { + /** 0 until a browser first writes; each write adds one */ + revision: number; + /** only the settings some browser has shared: one nobody changed is absent, and stays each device's own */ + settings: Partial>; + /** sidebar pin keys (`machine:pane`), in their order; absent until a browser first shares its pins */ + pins?: string[]; +} + +export const MAX_PINS = 500; +const MAX_PIN_LENGTH = 300; +/** a shared value is a few words or a short list; a change and the whole document it makes stay small */ +export const MAX_PREFERENCES_BYTES = 64 * 1024; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +/** What a browser sends: the settings it changed, and the pins when they changed */ +export interface PreferencesChange { settings: SharedPreferences["settings"]; pins?: string[] } + +/** The settings and pins of a body or a stored file, cut to the shared names and the limits; null when the shape is wrong. */ +export function sharedPreferencesBody(raw: unknown): PreferencesChange | null { + if (!isObject(raw) || !isObject(raw["settings"]) || (raw["pins"] !== undefined && !Array.isArray(raw["pins"]))) return null; + const pins = raw["pins"] as unknown[] | undefined; + if (pins !== undefined && (pins.length > MAX_PINS || !pins.every((pin) => typeof pin === "string" && pin.length > 0 && pin.length <= MAX_PIN_LENGTH))) return null; + const given = raw["settings"]; + const settings: SharedPreferences["settings"] = {}; + for (const key of SHARED_SETTING_KEYS) if (key in given) settings[key] = given[key]; + return pins === undefined ? { settings } : { settings, pins: [...new Set(pins as string[])] }; +} diff --git a/src/AGENTS.md b/src/AGENTS.md index f7c08c2f24..5fef24dca5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -65,6 +65,7 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | Sidebar badges | components/Sidebar.tsx `StatusBadge` + lib/status.ts | ONE vocabulary for every surface (sidebar, palette, composer status line): idle→READY, working→RUN, blocked→INPUT, done→DONE, unknown→`—` (`STATUS_WORD`, `knownStatus`), and a pane at rest with `background_wait`→BG (`paneStatus`, status `waiting`: the workspace row's rollup, the agent row, the palette, the tab dot and the composer's read-only word; send/queue/stop keep the raw status); the compact sidebar variant draws a glyph for RUN, INPUT, DONE and BG (RUN's arc, held still) and nothing for READY and unknown, but always renders the element with `data-status`, its label and a box (the browser scripts wait on it); background tasks are `.background-count`, not a `.badge`; the composer alone reads a blank state as READY (a shell is always ready); pushed statuses land through `applyPaneStatus` (lib/snapshot.ts) | | Notifications | lib/notifications.ts | tab alerts: policy re-exported from shared/notify-policy.ts; hidden-tab guard; shown via `registration.showNotification` (Android has no `new Notification()`), constructor only as fallback; skipped when this device has push (`pushOnRef`) | | Deep links | lib/deepLink.ts (pure) + App.tsx | the address names where the app is: `?machine&ws=-&pane&view&file&settings§ion&new&cwd&name&agent&sidebar` (`readLink`/`linkSearch`); App reads it once at load, settles it against the PC's roster once that is in (`resolveLink`: a slug another open workspace has wins over a reused id; a closed pane opens its workspace's row pane; a header note says what could not open), and writes it back from one effect: another pane or PC is `pushState` (Back returns to the last one), everything else `replaceState`; nothing is written while locked or before the roster is in; a pane picked before the link lands supersedes it; a link's lens is drawn as `notificationViewTarget`, never stored; Settings' own entries stay lib/settingsHistory.ts's, their address carrying `settings`/`section`; the New workspace dialog's fields reach the address 400 ms after they rest (Safari refuses bursts of history writes); a link never creates anything; a pane that mounts under an open modal does not take its focus (`modalOpen`, lib/focus.ts) | +| Shared settings and pins | lib/preferencesSync.ts (App, after the access gate) + `SETTING_SCOPE` in lib/settings.ts + lib/sidebarPins.ts store | every setting is `shared` or `device` in SETTING_SCOPE (the build fails on one that is neither; when in doubt, device); a server with a document gives it to a page on load, a load never writes; a change sends only the shared settings that changed (and the pins when they did) 600 ms after they rest, and the server lays them over what it holds, so a setting nobody changed stays each device's own; an event is ignored at or below the last revision seen, or while a change here is not yet sent (it goes out next and wins); a 403 (a watching device) stops sending and keeps taking what the others share; a 404 (an older server) turns sync off; the file reader (`?read=`) runs none of it | | Web push | lib/push.ts + App Alerts item (header More menu) + public/sw.js | `ensurePushSubscription` (SINGLE-FLIGHT: overlapping callers share one attempt, because two concurrent `subscribe()` calls make Chrome issue two subscriptions and keep only the later; idempotent; replaces a subscription made for another server key; runs on every load when permission is granted) and `removePushSubscription` (lock, before signOut); the Alerts item says off / pushed / this tab / in the app (permission not granted; a tap asks while it is unanswered, else switches off), and the More button carries a dot while alerts are off here; SW posts `{type:"select-pane", view:"chat"}` on a tapped pane alert, a closed app opens `/?pane=&view=chat`, a deep link like any other (lib/deepLink.ts), and App draws that pane's chat over its own lens (`notificationViewTarget`, render only: never in `lens` or `herdr-web-ui:view:`) until the pane or a lens is picked by hand; a pane with no agent keeps its own lens, and the in-app alert card (Droplet) opens the pane's own lens | | HTTP client | lib/api.ts | fetchSession / fetchHealth / authenticate / signOut; `ApiError` carries status + code | | App updates | lib/updates.ts + components/UpdateControls.tsx | authenticated status polling; Settings check/install + header notice; compare build-time Git revision to running server, offer Reload app explicitly so unsent drafts survive bridge updates | diff --git a/src/App.tsx b/src/App.tsx index a237e75c47..28adfb33c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { AgentMark } from "./components/AgentMark.tsx"; import { NewSessionDialog, type NewTabTarget } from "./components/NewSessionDialog.tsx"; import { MovePaneMenu } from "./components/MovePaneMenu.tsx"; import { passingFolder, recentDirectories } from "./lib/recent-directories.ts"; +import { usePreferencesSync } from "./lib/preferencesSync.ts"; import { TabStrip } from "./components/TabStrip.tsx"; import { SettingsDialog } from "./components/SettingsDialog.tsx"; import { loadedOnSettingsEntry, onSettingsHistory, recordSettings, settingsEntry } from "./lib/settingsHistory.ts"; @@ -326,6 +327,9 @@ export function App() { const [pushOn, setPushOn] = useState(false); const pushOnRef = useRef(pushOn); pushOnRef.current = pushOn; + // the shared settings and pins, the same in every browser of this server, once the app is in + const receivePreferences = usePreferencesSync(locked === false); + const receivePreferencesRef = useRef(receivePreferences); receivePreferencesRef.current = receivePreferences; const lockedRef = useRef(locked); lockedRef.current = locked; // last-seen agent status per pane: the baseline that decides whether a push is news @@ -460,6 +464,7 @@ export function App() { events.onmessage = (event) => { let payload: MachineEvent; try { payload = JSON.parse(event.data); } catch { return; } + if (payload.type === "preferences") { receivePreferencesRef.current(payload.preferences); return; } // A poll started before this event can carry an older roster or pane status. snapshotRequests.current.invalidate(); if (payload.type === "machines") { diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index 054d8c6c72..a991f75946 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ChevronDown, ChevronRight, Folder, Star, Terminal } from "lucide-react"; import type { Machine } from "../../shared/machines.ts"; @@ -9,7 +9,7 @@ import { agentContext, agentTabName, groupRows, paneMark, sidebarAgents, type Si import { useSettings } from "../lib/settings.ts"; import { useSidebarActivity } from "../lib/sidebarActivity.tsx"; import { activityOrder } from "../lib/sidebarOrder.ts"; -import { loadSidebarPins, pruneSidebarPins, saveSidebarPins, sidebarPinKey, splitPinnedRows, toggleSidebarPin } from "../lib/sidebarPins.ts"; +import { currentSidebarPins, pruneSidebarPins, setSidebarPins, sidebarPinKey, splitPinnedRows, subscribeSidebarPins, toggleSidebarPin } from "../lib/sidebarPins.ts"; import { AgentMark, hasAgentMark } from "./AgentMark.tsx"; import { BackgroundBadge, displayPaneTitle, PlanBadge, StatusBadge } from "./Sidebar.tsx"; import "./AgentSidebar.css"; @@ -77,7 +77,8 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const listId = useId(); const [collapsed, setCollapsed] = useState(() => window.matchMedia?.(DRAWER_QUERY).matches === true); const { settings } = useSettings(); - const [pinned, setPinned] = useState(loadSidebarPins); + // shared with the other browsers of this server (lib/preferencesSync.ts) + const pinned = useSyncExternalStore(subscribeSidebarPins, currentSidebarPins, currentSidebarPins); const activity = useSidebarActivity(); // Settings → Agents order: herdr's order, or Activity within each PC (each herdr counts its own changes) const byActivity = settings.agentOrder === "activity"; @@ -88,11 +89,6 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat // pins lead: alone, the pinned rows stand first; in cards, each stays in its own card, first there, // and a card that holds one comes before the cards that hold none const split = useMemo(() => splitPinnedRows(rows, settings.agentPins ? pinned : NO_PINS, ({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id)), [rows, pinned, settings.agentPins]); - useEffect(() => { - const live = new Set(rows.map(({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id))); - const next = pruneSidebarPins(pinned, live, machines); - if (next.length !== pinned.length) { setPinned(next); saveSidebarPins(next); } - }, [machines, rows, pinned]); // a pin moves the row, and maybe its card, so React may draw a new star: a star that had the // keyboard focus hands it to its successor const sectionRef = useRef(null); @@ -105,9 +101,10 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat }, [pinned]); const setPin = (key: string, star: HTMLButtonElement): void => { if (document.activeElement === star) refocusPin.current = key; - const next = toggleSidebarPin(pinned, key); - setPinned(next); - saveSidebarPins(next); + // pins of panes gone from this server leave with a change of the user's, never by themselves: the + // list is shared, and a pane this page has not seen yet (a PC still loading) is not gone + const live = new Set(rows.map(({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id))); + setSidebarPins(toggleSidebarPin(pruneSidebarPins(pinned, live, machines), key)); }; const carded = settings.agentCards && !byActivity; const groups = useMemo(() => !carded ? null : groupRows([...split.pinned, ...split.rest], ({ machine, entry }) => JSON.stringify([machine.id, entry.workspace.workspace_id])), [split, carded]); diff --git a/src/lib/api.ts b/src/lib/api.ts index 8caca2743b..8296b2b7a2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,4 @@ +import type { PreferencesChange, SharedPreferences } from "../../shared/preferences.ts"; import { machinePath, type BridgeHealth, type HerdrIdentity, type Machine, type SetupAction, type SetupJob, type SetupRequest } from "../../shared/machines.ts"; import type { AgentKind, @@ -106,6 +107,19 @@ export async function requestHerdrUpdate(): Promise { } /** Anonymous install and update counts; null from a server that sends none (404). */ +/** What every browser of this server shares (shared/preferences.ts); null where the server predates it. */ +export async function fetchPreferences(): Promise { + try { return await getJson("/api/preferences"); } + catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } +} + +export async function savePreferences(body: PreferencesChange): Promise { + const url = "/api/preferences"; + const response = await fetch(url, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + if (!response.ok) throw await errorFrom(url, response); + return (await response.json()) as SharedPreferences; +} + export async function fetchTelemetry(): Promise { try { return await getJson("/api/telemetry"); } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } diff --git a/src/lib/preferencesSync.test.ts b/src/lib/preferencesSync.test.ts new file mode 100644 index 0000000000..91e0cb9d04 --- /dev/null +++ b/src/lib/preferencesSync.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { DEFAULT_SETTINGS, sharedSettings, type Settings } from "./settings.ts"; +import { preferencesChange, takePreferences, type Baseline } from "./preferencesSync.ts"; + +const base = (settings: Settings, pins: readonly string[] | null): Baseline => ({ settings: sharedSettings(settings), pins }); +const light: Settings = { ...DEFAULT_SETTINGS, theme: "light" }; + +describe("preferences sync", () => { + it("sends only the shared settings that changed, and the pins only when they did", () => { + const known = base(DEFAULT_SETTINGS, ["local:w1:p1"]); + expect(preferencesChange(known, { settings: { ...DEFAULT_SETTINGS, terminalFontSize: 20, alertsOn: false }, pins: ["local:w1:p1"] })).toBeNull(); + expect(preferencesChange(known, { settings: { ...light, terminalFontSize: 20 }, pins: ["local:w1:p1"] })).toEqual({ settings: { theme: "light" } }); + expect(preferencesChange(known, { settings: DEFAULT_SETTINGS, pins: [] })).toEqual({ settings: {}, pins: [] }); + expect(preferencesChange(known, { settings: { ...DEFAULT_SETTINGS, usageOrder: ["claude"] }, pins: ["local:w1:p1"] })).toEqual({ settings: { usageOrder: ["claude"] } }); + }); + + it("while the server has held no pins, this browser's ride along with its first change, and alone they wait", () => { + const unknown = base(DEFAULT_SETTINGS, null); + expect(preferencesChange(unknown, { settings: DEFAULT_SETTINGS, pins: ["local:w1:p1"] })).toBeNull(); + expect(preferencesChange(unknown, { settings: light, pins: ["local:w1:p1"] })).toEqual({ settings: { theme: "light" }, pins: ["local:w1:p1"] }); + }); + + it("takes the shared settings the server holds and leaves the device's own", () => { + const mine: Settings = { ...light, terminalFontSize: 20, alertsOn: false }; + const taken = takePreferences(base(mine, null), { settings: mine, pins: [] }, { revision: 3, settings: { theme: "dark", palette: "forest" } }, null); + expect(taken.patch).toEqual({ theme: "dark", palette: "forest" }); + expect(taken.pins).toBeNull(); + expect(taken.base).toEqual({ settings: { ...sharedSettings(mine), theme: "dark", palette: "forest" }, pins: null }); + // nothing to send once taken + expect(preferencesChange(taken.base, { settings: { ...mine, ...taken.patch }, pins: [] })).toBeNull(); + }); + + it("keeps a change made here and not yet sent, which then goes out over the server's value", () => { + // the page opened with dark, the user chose light before the document arrived + const taken = takePreferences(base(DEFAULT_SETTINGS, null), { settings: light, pins: [] }, { revision: 1, settings: { theme: "dark", showThinking: false } }, null); + expect(taken.patch).toEqual({ showThinking: false }); + expect(taken.base.settings["theme"]).toBe("dark"); + expect(preferencesChange(taken.base, { settings: { ...light, showThinking: false }, pins: [] })).toEqual({ settings: { theme: "light" }, pins: [] }); + }); + + it("lets a later write from elsewhere win over a value already sent, and keeps the rest of the document", () => { + // light is on its way; meanwhile another browser set the palette, and then the theme back to dark + const inFlight = { settings: { theme: "light" } }; + const concurrent = takePreferences(base(DEFAULT_SETTINGS, []), { settings: light, pins: [] }, { revision: 2, settings: { theme: "light", palette: "forest" }, pins: [] }, inFlight); + expect(concurrent.patch).toEqual({ theme: "light", palette: "forest" }); + const overtaken = takePreferences(base(DEFAULT_SETTINGS, []), { settings: light, pins: [] }, { revision: 3, settings: { theme: "dark" }, pins: [] }, inFlight); + expect(overtaken.patch).toEqual({ theme: "dark" }); + expect(preferencesChange(overtaken.base, { settings: { ...light, theme: "dark" }, pins: [] })).toBeNull(); + }); + + it("leaves a value this browser cannot read, and does not send its own back", () => { + const taken = takePreferences(base(DEFAULT_SETTINGS, null), { settings: DEFAULT_SETTINGS, pins: [] }, { revision: 1, settings: { theme: "purple", agentCards: "yes", palette: "forest" } }, null); + expect(taken.patch).toEqual({ palette: "forest" }); + expect(preferencesChange(taken.base, { settings: { ...DEFAULT_SETTINGS, palette: "forest" }, pins: [] })).toBeNull(); + }); + + it("joins this browser's pins to the first the server holds, then follows the last write", () => { + const first = takePreferences(base(DEFAULT_SETTINGS, null), { settings: DEFAULT_SETTINGS, pins: ["local:w1:p1"] }, { revision: 1, settings: {}, pins: ["pc:w2:p3", "local:w1:p1"] }, null); + expect(first.pins).toEqual(["pc:w2:p3", "local:w1:p1"]); + expect(first.base.pins).toEqual(["pc:w2:p3", "local:w1:p1"]); + const joined = takePreferences(base(DEFAULT_SETTINGS, null), { settings: DEFAULT_SETTINGS, pins: ["local:w1:p1"] }, { revision: 1, settings: {}, pins: ["pc:w2:p3"] }, null); + expect(joined.pins).toEqual(["pc:w2:p3", "local:w1:p1"]); + // the join is this browser's change: it goes out + expect(preferencesChange(joined.base, { settings: DEFAULT_SETTINGS, pins: joined.pins! })).toEqual({ settings: {}, pins: ["pc:w2:p3", "local:w1:p1"] }); + const later = takePreferences(base(DEFAULT_SETTINGS, ["pc:w2:p3"]), { settings: DEFAULT_SETTINGS, pins: ["pc:w2:p3"] }, { revision: 2, settings: {}, pins: [] }, null); + expect(later.pins).toEqual([]); + // a pin made here and not yet sent stays + const pinning = takePreferences(base(DEFAULT_SETTINGS, ["pc:w2:p3"]), { settings: DEFAULT_SETTINGS, pins: ["pc:w2:p3", "local:w1:p1"] }, { revision: 2, settings: {}, pins: [] }, null); + expect(pinning.pins).toBeNull(); + expect(pinning.base.pins).toEqual([]); + }); + + it("a watching device takes everything", () => { + const taken = takePreferences(base(DEFAULT_SETTINGS, ["a:b"]), { settings: light, pins: ["a:b", "c:d"] }, { revision: 5, settings: { theme: "dark" }, pins: [] }, null, true); + expect(taken.patch).toEqual({ theme: "dark" }); + expect(taken.pins).toEqual([]); + }); +}); diff --git a/src/lib/preferencesSync.ts b/src/lib/preferencesSync.ts new file mode 100644 index 0000000000..20e45fd821 --- /dev/null +++ b/src/lib/preferencesSync.ts @@ -0,0 +1,173 @@ +/** + * Keeps the shared settings and the pins (shared/preferences.ts) the same in every browser of this + * server. Loaded once the app is in (never before the access gate): what the server holds is laid + * over this browser's settings, setting by setting; a setting nobody has shared yet stays this + * browser's own, and opening a page writes nothing. A change here sends just what changed, once the + * settings rest; another browser's arrives as a `preferences` event, and the server sends its whole + * document each time the event stream opens, so a reconnected page catches up. Last write wins, + * setting by setting: a change made here and not yet sent stays, and goes out over what arrived. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { SHARED_SETTING_KEYS, type PreferencesChange, type SharedPreferences, type SharedSettingKey } from "../../shared/preferences.ts"; +import { ApiError, fetchPreferences, savePreferences } from "./api.ts"; +import { sanitizeSettings, sharedSettings, useSettings, type Settings } from "./settings.ts"; +import { currentSidebarPins, setSidebarPins, subscribeSidebarPins } from "./sidebarPins.ts"; + +const SEND_AFTER_MS = 600; +/** a request that failed on the way (the network, a restart) is tried again after this */ +const RETRY_MS = 5_000; + +type Shared = Partial>; +/** What this browser last knew the server to hold; `pins` is null while the server has held none. */ +export interface Baseline { settings: Shared; pins: readonly string[] | null } +/** What this browser holds. */ +export interface Local { settings: Settings; pins: readonly string[] } + +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** + * What changed here since `base`: the shared settings that differ, and the pins when they do; null + * when nothing did. While the server has held no pins, this browser's ride along with its first change, + * so the pins of the browser that shares first are not lost. + */ +export function preferencesChange(base: Baseline, local: Local): PreferencesChange | null { + const now = sharedSettings(local.settings); + const settings = Object.fromEntries(Object.entries(now).filter(([key, value]) => !same(value, base.settings[key as SharedSettingKey]))); + const pinsChanged = base.pins !== null && !same(local.pins, base.pins); + if (Object.keys(settings).length === 0 && !pinsChanged) return null; + return pinsChanged || base.pins === null ? { settings, pins: [...local.pins] } : { settings }; +} + +export interface Taken { + base: Baseline; + /** the settings to set here */ + patch: Partial; + /** the pins to set here; null leaves them */ + pins: string[] | null; +} + +/** + * What a document from the server does here. Each setting it holds replaces this browser's, except + * one changed here since `base` and not yet sent (it goes out next and wins), and one this browser + * cannot read (a newer release's value stays the others'). `inFlight` is the change the document + * answers: a value the server took is no longer this browser's own, so the document's stands (a + * document from before the change landed is held until the answer, lib/preferencesSync.ts). With + * `force` (a watching device, which sends nothing) every value is taken. The + * baseline takes the server's values either way. The first pins the server holds join this browser's: + * nobody loses pins when a server begins sharing them; after that the last write wins. + */ +export function takePreferences(base: Baseline, local: Local, remote: SharedPreferences, inFlight: PreferencesChange | null, force = false): Taken { + const settings: Shared = { ...base.settings }; + const patch: Record = {}; + for (const key of SHARED_SETTING_KEYS) { + if (!(key in remote.settings)) continue; + const value = remote.settings[key]; + if (!same(sanitizeSettings({ ...local.settings, [key]: value })[key], value)) continue; + const sent = inFlight !== null && key in inFlight.settings && same(inFlight.settings[key], local.settings[key]); + const edited = !same(local.settings[key], base.settings[key]) && !sent; + settings[key] = value; + if (force || !edited) patch[key] = value; + } + let pins: string[] | null = null; + if (remote.pins !== undefined) { + if (base.pins === null && !force) pins = [...remote.pins, ...local.pins.filter((pin) => !remote.pins!.includes(pin))]; + else { + const sent = inFlight?.pins !== undefined && same(inFlight.pins, local.pins); + const edited = !same(local.pins, base.pins) && !sent; + if (force || !edited) pins = [...remote.pins]; + } + } + return { base: { settings, pins: remote.pins === undefined ? base.pins : [...remote.pins] }, patch: patch as Partial, pins }; +} + +export function usePreferencesSync(enabled: boolean): (preferences: SharedPreferences) => void { + const { settings, update } = useSettings(); + const pins = useSyncExternalStore(subscribeSidebarPins, currentSidebarPins, currentSidebarPins); + const latest = useRef({ settings, pins }); + latest.current = { settings, pins }; + // what this browser last knew the server to hold; until a document arrives, what it holds itself + const base = useRef({ settings: sharedSettings(settings), pins: null }); + const revision = useRef(0); + // a document has arrived (the GET, or the event stream's opening): sending begins then + const loadedRef = useRef(false); + const [loaded, setLoaded] = useState(false); + // a server without the route: sync stands down, settings stay this browser's + const off = useRef(false); + // a watching device: it takes what the others share and sends nothing; a change of its own lasts until the next shared one + const readOnly = useRef(false); + const inFlight = useRef(null); + // the newest document that arrived while a change was on its way: whether it is older or newer + // than that change shows once the answer, with its revision, is here + const held = useRef(null); + // the send effect runs again when this moves: after a send, or a failed one's wait + const [attempt, setAttempt] = useState(0); + const [loadAttempt, setLoadAttempt] = useState(0); + // the baseline is a ref: a document that changes nothing here still moves what is pending + const [, setTaken] = useState(0); + + const absorb = useCallback((remote: SharedPreferences) => { + if (off.current || (loadedRef.current && remote.revision <= revision.current)) return; + revision.current = Math.max(revision.current, remote.revision); + const taken = takePreferences(base.current, latest.current, remote, inFlight.current, readOnly.current); + base.current = taken.base; + // a second document in the same tick compares against what this one set, not the last render + latest.current = { settings: sanitizeSettings({ ...latest.current.settings, ...taken.patch }), pins: taken.pins ?? latest.current.pins }; + if (Object.keys(taken.patch).length > 0) update(taken.patch); + if (taken.pins !== null) setSidebarPins(taken.pins); + loadedRef.current = true; + setLoaded(true); + setTaken((count) => count + 1); + }, [update]); + + const receive = useCallback((remote: SharedPreferences) => { + if (inFlight.current === null) { absorb(remote); return; } + if (held.current === null || remote.revision > held.current.revision) held.current = remote; + }, [absorb]); + + // the change is answered or lost: what arrived meanwhile is read now (an older document is dropped by its revision) + const settled = useCallback(() => { + inFlight.current = null; + const arrived = held.current; + held.current = null; + if (arrived !== null) absorb(arrived); + }, [absorb]); + + useEffect(() => { + if (!enabled || off.current || loaded) return; + let cancelled = false; + let retry: number | undefined; + fetchPreferences().then((remote) => { + if (cancelled) return; + if (remote === null) { off.current = true; return; } + receive(remote); + }).catch(() => { if (!cancelled) retry = window.setTimeout(() => setLoadAttempt((count) => count + 1), RETRY_MS); }); + return () => { cancelled = true; window.clearTimeout(retry); }; + }, [enabled, loaded, loadAttempt, receive]); + + const change = loaded ? preferencesChange(base.current, { settings, pins }) : null; + const pending = change === null ? null : JSON.stringify(change); + useEffect(() => { + if (!enabled || !loaded || off.current || readOnly.current || pending === null || inFlight.current !== null) return; + let retry: number | undefined; + const timer = window.setTimeout(() => { + const body = JSON.parse(pending) as PreferencesChange; + inFlight.current = body; + savePreferences(body).then((saved) => { + // the answer is the whole document as this change left it: one from elsewhere that landed + // before comes with it, one that landed after was held and is read now + absorb(saved); + settled(); + // what changed while this was on its way goes out now + setAttempt((count) => count + 1); + }).catch((error: unknown) => { + settled(); + if (error instanceof ApiError && error.status === 403) readOnly.current = true; + else if (error instanceof ApiError && error.status === 404) off.current = true; + else retry = window.setTimeout(() => setAttempt((count) => count + 1), RETRY_MS); + }); + }, SEND_AFTER_MS); + return () => { window.clearTimeout(timer); window.clearTimeout(retry); }; + }, [enabled, loaded, pending, attempt, absorb, settled]); + + return receive; +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 748e0c81ee..e64dec9a7a 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -4,7 +4,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { FONT_FAMILY_MAX_CHARS } from "./fontFamily.ts"; import { DEFAULT_KEY_BAR_ITEMS, migrateKeyBarItems } from "./keyBar.ts"; -import { changedSettings, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, wantsVoiceInput } from "./settings.ts"; +import { SHARED_SETTING_KEYS } from "../../shared/preferences.ts"; +import { SETTING_SCOPE, changedSettings, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, wantsVoiceInput } from "./settings.ts"; it("keeps a tab's pane while out of use until this device turns the pause on", () => { expect(DEFAULT_SETTINGS.releasePaneAway).toBe(false); @@ -575,3 +576,11 @@ it("highlights code unless turned off, and keeps a stored choice only when it is expect(sanitizeSettings({ highlightCode: "no" }).highlightCode).toBe(true); expect(sanitizeSettings({}).highlightCode).toBe(true); }); + +describe("shared settings", () => { + it("shares exactly the settings SETTING_SCOPE marks shared", () => { + const shared = Object.entries(SETTING_SCOPE).filter(([, scope]) => scope === "shared").map(([key]) => key).sort(); + expect([...SHARED_SETTING_KEYS].sort() as string[]).toEqual(shared); + expect(Object.keys(SETTING_SCOPE).sort()).toEqual(Object.keys(DEFAULT_SETTINGS).sort()); + }); +}); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index dd8ad42fcd..ee7e2c0e08 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -6,6 +6,7 @@ */ import { REPORTED_SETTINGS, type ReportedSetting } from "../../shared/telemetry.ts"; +import { SHARED_SETTING_KEYS, type SharedSettingKey } from "../../shared/preferences.ts"; import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { LANGUAGE_SETTINGS, LOCALE_TAGS, resolveLanguage, setCurrentLanguage, type Language, type LanguageSetting } from "./i18n.ts"; import type { AlertPrefs, DoneAlerts } from "../../shared/notify-policy.ts"; @@ -157,6 +158,29 @@ export function changedSettings(settings: Settings): ReportedSetting[] { return REPORTED_SETTINGS.filter((name) => settings[name] !== DEFAULT_SETTINGS[name]); } +/** + * Which settings every browser of this server shares (shared/preferences.ts, lib/preferencesSync.ts) + * and which stay this device's: its screen, keyboard, terminal, alerts and microphone. A new setting + * must be placed here, or the build fails; when in doubt it is the device's. + */ +export const SETTING_SCOPE = { + theme: "shared", palette: "shared", language: "shared", sidebarRows: "shared", agentOrder: "shared", agentCards: "shared", + agentPins: "shared", autoSendQueued: "shared", showThinking: "shared", quickReplies: "shared", + showQuickReplies: "shared", showSuggestionChip: "shared", showUsage: "shared", usageCount: "shared", usagePace: "shared", + usageGlance: "shared", usageOrder: "shared", usageHidden: "shared", highlightCode: "shared", defaultView: "device", + terminalInputMode: "device", keyBarExtras: "device", keyBarItems: "device", shortcutOverrides: "device", density: "device", + quietOpenedDone: "device", terminalFontSize: "device", terminalCursorStyle: "device", terminalCursorBlink: "device", + terminalWheelSpeed: "device", terminalFontFamily: "device", paneClipboard: "device", releasePaneAway: "device", + narrowPaneFont: "device", chatFontSize: "device", chatFontFamily: "device", chatWidth: "device", enterSends: "device", + keepScreenOn: "device", alertsOn: "device", alertInput: "device", alertDone: "device", alertInApp: "device", alertSound: "device", + voiceInput: "device", voiceLanguage: "device", voicePolishChat: "device", voicePolishTerminal: "device", +} as const satisfies Record; + +/** The shared part of `settings`, by the names shared/preferences.ts lists. */ +export function sharedSettings(settings: Settings): Partial> { + return Object.fromEntries(SHARED_SETTING_KEYS.map((key) => [key, settings[key]])); +} + export const DEFAULT_SETTINGS: Settings = { terminalInputMode: "auto", keyBarExtras: ["alt"], diff --git a/src/lib/sidebarPins.ts b/src/lib/sidebarPins.ts index 744bedea27..c9174fa03c 100644 --- a/src/lib/sidebarPins.ts +++ b/src/lib/sidebarPins.ts @@ -29,6 +29,32 @@ export function saveSidebarPins(keys: readonly string[]): void { catch { /* storage denied: pins last for the session */ } } +/** + * The pins this page holds, for the sidebar and for the sync with the other browsers + * (lib/preferencesSync.ts): set here, they are saved and every reader hears it. + */ +let pins: string[] | null = null; +const pinListeners = new Set<() => void>(); + +export function currentSidebarPins(): string[] { + pins ??= loadSidebarPins(); + return pins; +} + +export function setSidebarPins(keys: readonly string[]): void { + const next = [...new Set(keys)]; + const current = currentSidebarPins(); + if (next.length === current.length && next.every((key, index) => key === current[index])) return; + pins = next; + saveSidebarPins(next); + for (const listener of [...pinListeners]) listener(); +} + +export function subscribeSidebarPins(listener: () => void): () => void { + pinListeners.add(listener); + return () => { pinListeners.delete(listener); }; +} + export function toggleSidebarPin(keys: readonly string[], key: string): string[] { return keys.includes(key) ? keys.filter((current) => current !== key) : [...keys, key]; }