From cb24bcf5b02287039501a4f68171bd48f1355f4a Mon Sep 17 00:00:00 2001
From: thefahien <333489353+thefahien@users.noreply.github.com>
Date: Thu, 22 Oct 2026 18:26:09 +0000
Subject: [PATCH 1/6] feat: show each PC's CPU, memory and fullest disk in the
sidebar
---
DESIGN.md | 18 +++
changelog.d/pc-system-load.md | 5 +
docs/guide.md | 1 +
server/api.contract.test.ts | 22 +++
server/dispatch.ts | 5 +-
server/index.ts | 6 +-
server/machine-api.ts | 2 +-
server/machines.test.ts | 2 +
server/system-stats.test.ts | 158 +++++++++++++++++++++
server/system-stats.ts | 219 ++++++++++++++++++++++++++++++
shared/system.ts | 31 +++++
site/demo/transport.ts | 13 ++
src/components/MachineSidebar.tsx | 2 +
src/components/MachineStats.css | 95 +++++++++++++
src/components/MachineStats.tsx | 83 +++++++++++
src/lib/api.ts | 10 ++
src/lib/i18n.test.ts | 4 +-
src/lib/i18n/machines.ja.ts | 19 ++-
src/lib/i18n/machines.ko.ts | 19 ++-
src/lib/i18n/machines.zh.ts | 19 ++-
src/lib/system.test.ts | 28 ++++
src/lib/system.ts | 67 +++++++++
src/lib/systemReport.test.ts | 59 ++++++++
src/lib/systemReport.ts | 69 ++++++++++
24 files changed, 948 insertions(+), 8 deletions(-)
create mode 100644 changelog.d/pc-system-load.md
create mode 100644 server/system-stats.test.ts
create mode 100644 server/system-stats.ts
create mode 100644 shared/system.ts
create mode 100644 src/components/MachineStats.css
create mode 100644 src/components/MachineStats.tsx
create mode 100644 src/lib/system.test.ts
create mode 100644 src/lib/system.ts
create mode 100644 src/lib/systemReport.test.ts
create mode 100644 src/lib/systemReport.ts
diff --git a/DESIGN.md b/DESIGN.md
index 3137239101..5f58564f3f 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1583,6 +1583,24 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
the email or login right-aligned and ellipsized, then one row per limit (label, reset time, right-aligned percent) over a 4px bar. A problem
note is dim, red for an expired sign-in or a failed request.
+### PC load (`.machine-stats`)
+- Under an expanded, connected PC's name and above its workspaces: one button holding three
+ meters in a row, each a label, the percent in `tabular-nums` and a 3px bar on a `--border-strong`
+ track (the plan meters' construction): **CPU**, **Memory** (without the file cache) and
+ **Disk** (the fullest of the disks that hold the root and home folder), and a caret at the end
+ that opens the numbers behind them: memory used and total, load average and core count, uptime
+ and a bar per disk. From 85% the percent and bar take `--status-blocked`, as the plan meters do
+ from 80%; amber stays chrome. The open or closed choice is kept per PC in this browser.
+- It is drawn only for a PC that can say: a PC whose bridge is from before `GET /api/system`
+ shows nothing, and so does one that has not answered yet. A figure the PC cannot measure
+ is a dash, never `0%`. The meters are drawn only; the button's label carries the numbers for a
+ screen reader ("Show system details for Work PC. CPU 4%, Memory 22%, Disk 50%").
+- Nothing is read for a PC that is folded, offline or in a hidden tab. The next ask starts 5 s
+ after the last one came back or timed out, so a stuck PC never has more than one open.
+- Reference: no app listed in `src/AGENTS.md` shows a machine's load in its project list that I
+ know of (they assume one machine); this follows the app's own plan meters and PC group header,
+ and differs only in measuring a PC instead of a subscription. No code is borrowed.
+
### New workspace dialog
- Agent select comes from `GET /api/agents`; shell-only is always available. The bridge offers the
agents it types into the pane's shell itself (omo, gjc) exactly where it can start them: its own
diff --git a/changelog.d/pc-system-load.md b/changelog.d/pc-system-load.md
new file mode 100644
index 0000000000..c42283941e
--- /dev/null
+++ b/changelog.d/pc-system-load.md
@@ -0,0 +1,5 @@
+### Added
+- Each PC in the sidebar shows its CPU, memory and fullest disk as three small meters under its
+ name. Tap them for memory used, load average, uptime and every disk. A figure at 85% or more
+ turns red. Nothing is read for a PC that is folded, offline or in a background tab, and a PC
+ whose bridge is from before this release shows nothing until its bridge is updated.
diff --git a/docs/guide.md b/docs/guide.md
index 91e6277eeb..d35b8fe8cf 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -334,6 +334,7 @@ across an Ovis restart, which forgets where it saw each `letta` before. Check th
| **Search the conversation** | **Search conversation**, from the pane's menu (right-click on a desktop, the tab's ⋯ on a phone) or the command palette, finds a word in what you wrote and in the agent's answers, earlier pages included; tool output, thinking and progress notes are left out. Matches list newest first as snippets; picking one opens the chat at that message, loading the earlier messages up to it. Nothing in the chat is highlighted, and the phone's screen gains no button. A remote PC offers it once its bridge is updated to one that searches. |
| **Find in terminal** | **More → Find in terminal** or Mod+Shift+F searches literal text in herdr's history, with a match count, previous/next navigation and visible match highlights. Search jumps and scrolling move the same viewport in every attached client, including the TUI. Older bridges retain search navigation without automatic highlights. |
| **Jump across PCs** | The command palette (Mod+Shift+K) searches all connected PCs at once by PC, workspace, branch, tab, pane title or agent kind. PC names distinguish results, and Recent remembers each pane's PC. Picking a result opens that PC and pane directly. Disconnected PCs are omitted; app and plugin actions remain on the currently selected PC. It also finds Settings pages and actions by related words (try `theme` or `font`), and browses folders: type a path such as `~/`, or pick **Browse folders**, and the folder you reach opens in Files. |
+| **See each PC's load** | Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open and the page is visible. A remote PC shows nothing until its bridge is updated to this version. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS leaves memory out. |
| **Answer prompts** | Approval, question and plan menus become cards. Tap an option, or type its number in the composer. The server checks that the menu is still current before answering. |
| **Compose** | `/` commands and `@` file mentions, any file or image up to 32 MB attached by path, and a draft per pane. Send during work schedules a next-turn message; use its ↑ Send now action to send it now. A strip under the input card names the pane's working folder (the whole path on hover) and, for a workspace of a repository with a worktree open, its branch at the other end; a phone leaves it out. Pick or type a terminal-only command and send normally: after acceptance, the pane's terminal opens over the chat, in this browser only, with **Back to Chat** and **Keep Terminal**; the chat stays where you were reading. For Claude Code's panels (`/config`, `/permissions`, `/theme` and the like) the chat may come back by itself when you close the panel, once Claude records the close; for Codex, omp, pi, gjc, OmO, Hermes (classic CLI), Devin, agy and Grok it comes back after their recorded pickers and panels, once the agent's empty input row is back on the screen; otherwise use **Back to Chat**, the header's Chat, or Back on a phone. If you go back to the same pane's chat while the command's screen is still open, the composer says so (Claude Code and every agent above, not OpenCode) and holds Send, quick replies and a queued message's Send now until the screen closes (**Send anyway** sends the message once); **Show terminal** opens it again. Messages the PC sends by itself at the end of a turn are not held. Automatic queued delivery never changes your view. Chat keeps one quiet **Ran in terminal** row with **Open Terminal**, which shows the terminal over the chat again; ordinary commands keep their outcome chips, and Hermes keeps its informational result cards. `/resume` opens **Earlier sessions…**. [Agent commands in Chat](agent-commands.md) explains where each agent's commands come from and what counts as a result. |
| **Follow every agent** | Live RUN / INPUT / DONE / READY status for all panes, and alerts when an agent needs input, finishes or its terminal ends. |
diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts
index c191ee0441..54a0a6571a 100644
--- a/server/api.contract.test.ts
+++ b/server/api.contract.test.ts
@@ -1,3 +1,4 @@
+import type { SystemReport } from "../shared/system.ts";
import { describe, expect, it, beforeAll, afterAll, spyOn } from "bun:test";
import { CLEAR_LEGACY_DEVICE_COOKIE, DEVICE_COOKIE_CLEARED } from "./auth.ts";
import { Database } from "bun:sqlite";
@@ -652,6 +653,27 @@ describe("usage API", () => {
});
});
+describe("system API", () => {
+ const report: SystemReport = { sampled_at: 1, platform: "linux", uptime_s: 5, cpu_count: 2, cpu_percent: 10, memory: null, load: null, disks: [] };
+ const sampler = { read: async () => report };
+
+ it("answers GET without caching, keeps it behind the token gate, and takes the bridge token too", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "herdr-system-contract-"));
+ const open = createServer({ port: 0, stateDir: dir, system: sampler });
+ const gated = createServer({ port: 0, stateDir: dir, system: sampler, token: "system-test-token" });
+ try {
+ const answered = await fetch(`http://localhost:${open.port}/api/system`);
+ expect(answered.status).toBe(200);
+ expect(answered.headers.get("cache-control")).toBe("no-store");
+ expect(await answered.json()).toEqual(report);
+ expect((await fetch(`http://localhost:${gated.port}/api/system`)).status).toBe(401);
+ const withToken = await fetch(`http://localhost:${gated.port}/api/system`, { headers: { authorization: "Bearer system-test-token" } });
+ expect(withToken.status).toBe(200);
+ expect((await fetch(`http://localhost:${open.port}/api/system`, { method: "POST", headers: { origin: `http://localhost:${open.port}` } })).status).toBe(405);
+ } finally { open.stop(); gated.stop(); rmSync(dir, { recursive: true, force: true }); }
+ });
+});
+
describe("voice API", () => {
it("keeps the key on the server, refuses cross-site writes and streams a transcript", async () => {
const voiceState = mkdtempSync(join(tmpdir(), "herdr-voice-contract-"));
diff --git a/server/dispatch.ts b/server/dispatch.ts
index 96b3c8627b..ad89bc1c99 100644
--- a/server/dispatch.ts
+++ b/server/dispatch.ts
@@ -47,6 +47,7 @@ import type { RelayService } from "./relay/service.ts";
import { handleTelemetryRequest, visitSurface } from "./telemetry.ts";
import type { TelemetryIngress } from "../shared/telemetry.ts";
import { handleStatuslineRequest, handleUsageRequest } from "./usage.ts";
+import { handleSystemRequest, type SystemSampler } from "./system-stats.ts";
import type { UsageAccountList } from "./usage-account-list.ts";
import { handleVoiceRequest } from "./voice.ts";
import { BRIDGE_PROTOCOL } from "../shared/machines.ts";
@@ -95,6 +96,7 @@ export interface RequestServices {
readonly usageLogin: UsageLoginService;
readonly usage: UsageService;
readonly usageAccounts: UsageAccountList;
+ readonly systemSampler: SystemSampler;
readonly voice: VoiceService;
readonly browserSnapshot: (snapshot: S) => S;
readonly core: ReturnType;
@@ -143,7 +145,7 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
// custom header (update-api.ts), and whoever holds the token already drives every pane here. Only those two
// requests, each by its own method and with no query: the token opens nothing else of these routes (#2054)
const bridgeUpdatePath = url.search === "" && (pathname === "/api/updates" ? request.method === "GET" : pathname === "/api/updates/install" && request.method === "POST");
- const bridgePath = pathname === "/api/bridge" || pathname === "/api/session" || pathname === SESSION_HISTORY_PATH || pathname === SESSION_RESUME_PATH || pathname === "/api/agents" || pathname === "/api/agent-catalog" || pathname === INSTALL_PATH || pathname === SIGN_IN_PATH || pathname === "/api/agent/rename" || pathname === "/api/agent/commands" || pathname === "/api/integrations" || pathname === "/api/usage/statusline" || pathname === "/api/plugins/actions" || pathname === "/api/plugin/action" || pathname.startsWith("/api/pane/") || pathname.startsWith("/api/workspace/") || pathname.startsWith("/api/worktree/") || pathname.startsWith("/api/tab/") || pathname === "/api/layout/ratio" || pathname.startsWith("/api/fs/") || pathname === "/ws" || bridgeUpdatePath;
+ const bridgePath = pathname === "/api/bridge" || pathname === "/api/session" || pathname === SESSION_HISTORY_PATH || pathname === SESSION_RESUME_PATH || pathname === "/api/agents" || pathname === "/api/agent-catalog" || pathname === INSTALL_PATH || pathname === SIGN_IN_PATH || pathname === "/api/agent/rename" || pathname === "/api/agent/commands" || pathname === "/api/integrations" || pathname === "/api/system" || pathname === "/api/usage/statusline" || pathname === "/api/plugins/actions" || pathname === "/api/plugin/action" || pathname.startsWith("/api/pane/") || pathname.startsWith("/api/workspace/") || pathname.startsWith("/api/worktree/") || pathname.startsWith("/api/tab/") || pathname === "/api/layout/ratio" || pathname.startsWith("/api/fs/") || pathname === "/ws" || bridgeUpdatePath;
const ip = relayPeer === undefined ? ctx.requestIP(request) : null;
const loopback = ip !== null && isLoopbackAddress(ip.address);
const forwarded = relayPeer === undefined && cameThroughProxy(request.headers);
@@ -464,6 +466,7 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
// the finishes read in some browser of this server; a watching device reads them and cannot add to them (above)
if (pathname === "/api/seen") { setRequestRoute(pathname); return handleSeenRequest(request, seen); }
+ if (pathname === "/api/system") { setRequestRoute(pathname); return handleSystemRequest(request, ctx.services.systemSampler); }
if (pathname === "/api/usage") { setRequestRoute(pathname); return handleUsageRequest(request, url, ctx.services.usageAccounts); }
if (pathname === "/api/usage/accounts") { setRequestRoute(pathname); return ctx.services.usageAccounts.handle(request); }
// on or off only: the server decides what is installed and where (server/statusline-install.ts)
diff --git a/server/index.ts b/server/index.ts
index d7f6a6c8c5..a259a27508 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -43,6 +43,7 @@ import { AccountMailbox, accountServiceUrl } from "./account/mailbox.ts";
import { AccountIntroductions } from "./account/introductions.ts";
import { Telemetry } from "./telemetry.ts";
import { UsageService } from "./usage.ts";
+import { createSystemSampler, type SystemSampler } from "./system-stats.ts";
import { CLIProxyService } from "./cliproxy.ts";
import { CLIProxyRoutes } from "./cliproxy-routes.ts";
import { UsageLoginService } from "./usage-login.ts";
@@ -166,6 +167,8 @@ export function createServer(
telemetry?: Telemetry;
/** plan limits of the AI subscriptions signed in here; tests pass one without real sign-ins */
usage?: UsageService;
+ /** this PC's load; tests pass one that reads nothing from the machine */
+ system?: SystemSampler;
/** Tests supply an isolated proxy service, never a user's configured instance. */
cliProxy?: CLIProxyService;
/** Tests use a disposable credential home and stand-in CLI binaries. */
@@ -269,6 +272,7 @@ export function createServer(
usageLogin.persistTo(stateDir);
const usage = options.usage ?? new UsageService(undefined, undefined, (refresh) => cliProxy.report(refresh));
const usageAccounts = new UsageAccountList(stateDir, usage);
+ const systemSampler = options.system ?? createSystemSampler();
// a sign-in the browser followed ends in the server's own PTY, not a herdr pane: it says so itself
usageLogin.onLoginEnded((account) => {
usage.invalidate();
@@ -404,7 +408,7 @@ export function createServer(
service: (() => { try { return accountServiceUrl(options.accountService?.url); } catch (error) { return error instanceof Error ? error : new Error(String(error)); } })(), ...(options.accountService?.fetch ? { fetch: options.accountService.fetch } : {}),
});
const services: RequestServices = {
- options, relay: ovisRelay, relayReceiptIds: new RelayReceiptIds(), hostname, token, bridgeToken, bootId, devices, accountMailbox, accountIntroductions: new AccountIntroductions(), preferences, herdrSidebar, identityOf, serveOnly, portalGuards, shellAgents, agentDetector, agentOffered, agentInstaller, openCodeState, cliProxyRoutes, usageLogin, usage, usageAccounts, voice, browserSnapshot, core, seen, piReader, orderSequence, clientConnectionLog, requestLog, portalStartup,
+ options, relay: ovisRelay, relayReceiptIds: new RelayReceiptIds(), hostname, token, bridgeToken, bootId, devices, accountMailbox, accountIntroductions: new AccountIntroductions(), preferences, herdrSidebar, identityOf, serveOnly, portalGuards, shellAgents, agentDetector, agentOffered, agentInstaller, openCodeState, cliProxyRoutes, usageLogin, usage, usageAccounts, systemSampler, voice, browserSnapshot, core, seen, piReader, orderSequence, clientConnectionLog, requestLog, portalStartup,
get portalStartupResult() { return portalStartupResult; },
};
ovisRelay.resume(services, server.port ?? DEFAULT_PORT);
diff --git a/server/machine-api.ts b/server/machine-api.ts
index 054cd9dc69..3b1e933938 100644
--- a/server/machine-api.ts
+++ b/server/machine-api.ts
@@ -99,7 +99,7 @@ export const slowMachinePath = (path: string): boolean => path === "worktree/cre
* forwards, so such a path must be no route at all rather than "some other route".
*/
export const nonCanonicalPath = (pathname: string): boolean => pathname.includes("//") || (pathname.length > 1 && pathname.endsWith("/")) || /%2f|%5c/i.test(pathname);
-export const MACHINE_PROXY_PATH = /^(?:session|sessions\/(?:history|resume)|agents|agent-catalog(?:\/(?:install|sign-in))?|integrations|usage\/statusline|pane\/(?:read|scroll|find|selection|conversation(?:\/image|\/tool-output)?|commands|files|omo-tasks|background-agent|plan|artifacts|btw|prompt|prompt\/answer|input|keys|close|rename|image|split|zoom|swap|move|resize|clear|focus)|workspace\/(?:create|rename|move|close|directories|diff\/files|diff\/file|diff\/refs|pr|pr\/log|pr\/merge)|worktree\/(?:create|list|open|remove)|tab\/(?:create|rename|close|move|move-to-workspace)|layout\/ratio|agent\/(?:rename|commands)|plugins\/actions|plugin\/action|fs\/(?:stat|file|favicon))$/;
+export const MACHINE_PROXY_PATH = /^(?:session|sessions\/(?:history|resume)|agents|agent-catalog(?:\/(?:install|sign-in))?|integrations|system|usage\/statusline|pane\/(?:read|scroll|find|selection|conversation(?:\/image|\/tool-output)?|commands|files|omo-tasks|background-agent|plan|artifacts|btw|prompt|prompt\/answer|input|keys|close|rename|image|split|zoom|swap|move|resize|clear|focus)|workspace\/(?:create|rename|move|close|directories|diff\/files|diff\/file|diff\/refs|pr|pr\/log|pr\/merge)|worktree\/(?:create|list|open|remove)|tab\/(?:create|rename|close|move|move-to-workspace)|layout\/ratio|agent\/(?:rename|commands)|plugins\/actions|plugin\/action|fs\/(?:stat|file|favicon))$/;
/**
diff --git a/server/machines.test.ts b/server/machines.test.ts
index cc94f3d934..2ac1937f78 100644
--- a/server/machines.test.ts
+++ b/server/machines.test.ts
@@ -362,6 +362,8 @@ describe("machine boundaries", () => {
// the install catalog a PC answers for itself, and an install or sign-in started on that PC; nothing else under it
for (const path of ["agent-catalog", "agent-catalog/install", "agent-catalog/sign-in"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true);
for (const path of ["agent-catalog/uninstall", "agent-catalog/install/extra", "agent-catalog/../auth"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(false);
+ expect(MACHINE_PROXY_PATH.test("system")).toBe(true);
+ for (const path of ["system/extra", "systems", "system?x=1", "../system"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(false);
for (const path of ["auth", "push", "updates/install", "machines/setup", "bridge", "../auth", "pane/../../auth", "pane/prompt/answer/extra", "pane/rewind", "tab/move/extra", "tab/close/extra"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(false);
for (const path of ["session", "agents", "integrations", "pane/files", "pane/find", "pane/image", "pane/prompt/answer", "pane/omo-tasks", "pane/background-agent", "pane/btw", "pane/input", "workspace/create", "tab/create", "tab/rename", "tab/close"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true);
for (const path of ["session", "agents", "integrations", "pane/files", "pane/find", "pane/image", "pane/prompt/answer", "pane/split", "pane/zoom", "pane/swap", "pane/resize", "pane/clear", "workspace/create", "tab/create", "tab/rename", "tab/close"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true);
diff --git a/server/system-stats.test.ts b/server/system-stats.test.ts
new file mode 100644
index 0000000000..f8e765a8ab
--- /dev/null
+++ b/server/system-stats.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from "bun:test";
+import { cpuPercent, cpuTimesOf, createSystemSampler, diskOf, handleSystemRequest, parseMeminfo, STALE_MS, type SystemIo } from "./system-stats.ts";
+
+const MEMINFO = `MemTotal: 16384000 kB
+MemFree: 1000000 kB
+MemAvailable: 8192000 kB
+Buffers: 500000 kB
+`;
+
+describe("system readings", () => {
+ it("reads memory as total minus MemAvailable, so the file cache is not used memory", () => {
+ expect(parseMeminfo(MEMINFO)).toEqual({ total: 16384000 * 1024, used: 8192000 * 1024 });
+ expect(parseMeminfo("MemTotal: 100 kB\n")).toBeNull();
+ expect(parseMeminfo("")).toBeNull();
+ });
+
+ it("measures CPU as the change between two readings", () => {
+ const core = (user: number, idle: number) => ({ times: { user, nice: 0, sys: 0, idle, irq: 0 } });
+ const before = cpuTimesOf([core(100, 900), core(100, 900)]);
+ const after = cpuTimesOf([core(150, 950), core(200, 900)]);
+ expect(cpuPercent(before, after)).toBe(75);
+ expect(cpuPercent(before, before)).toBeNull();
+ });
+
+ it("reports a disk as df does and leaves out one too small to fill up", () => {
+ const gib = 1024 ** 3;
+ // 100 GiB filesystem, 5 GiB reserved for root: Use% is used over used plus what a user can still fill
+ const disk = diskOf("/", { bsize: 4096, blocks: (100 * gib) / 4096, bfree: (35 * gib) / 4096, bavail: (30 * gib) / 4096 });
+ expect(disk).toEqual({ path: "/", used: 65 * gib, total: 95 * gib, percent: 68.4 });
+ expect(diskOf("/boot", { bsize: 4096, blocks: 1000, bfree: 10, bavail: 10 })).toBeNull();
+ });
+});
+
+function fakeIo(over: Partial = {}) {
+ let clock = 1_000_000;
+ let busy = 0;
+ let total = 0;
+ const calls = { cpu: 0 };
+ const io: SystemIo = {
+ platform: "linux",
+ now: () => clock,
+ sleep: async (ms) => { clock += ms; },
+ cpuTimes: () => { calls.cpu += 1; busy += 25; total += 100; return { busy, total }; },
+ cpuCount: () => 4,
+ uptime: () => 3600.4,
+ loadavg: () => [0.5, 0.25, 0.125],
+ memory: async () => ({ total: 1000, used: 250 }),
+ disks: async () => [],
+ ...over,
+ };
+ return { io, calls, advance: (ms: number) => { clock += ms; } };
+}
+
+describe("the sampler", () => {
+ it("waits a moment on its first reading so CPU has something to compare", async () => {
+ const { io } = fakeIo();
+ const report = await createSystemSampler(io).read();
+ expect(report.cpu_percent).toBe(25);
+ expect(report.memory).toEqual({ used: 250, total: 1000, percent: 25 });
+ expect(report.load).toEqual([0.5, 0.25, 0.13]);
+ expect(report.uptime_s).toBe(3600);
+ expect(report.cpu_count).toBe(4);
+ });
+
+ it("answers asks closer than the fresh window from one reading", async () => {
+ const { io, calls, advance } = fakeIo();
+ const sampler = createSystemSampler(io);
+ const first = await sampler.read();
+ const used = calls.cpu;
+ advance(500);
+ expect(await sampler.read()).toBe(first);
+ expect(calls.cpu).toBe(used);
+ advance(2000);
+ expect((await sampler.read()).sampled_at).toBeGreaterThan(first.sampled_at);
+ });
+
+ it("shares one reading between asks that arrive together", async () => {
+ const { io, calls } = fakeIo();
+ const sampler = createSystemSampler(io);
+ const [a, b] = await Promise.all([sampler.read(), sampler.read()]);
+ expect(a).toBe(b);
+ expect(calls.cpu).toBe(2);
+ });
+
+ it("says null for what a PC cannot measure, never zero", async () => {
+ const { io } = fakeIo({ platform: "win32", memory: async () => null });
+ const report = await createSystemSampler(io).read();
+ expect(report.platform).toBe("win32");
+ expect(report.load).toBeNull();
+ expect(report.memory).toBeNull();
+ });
+
+ it("starts a fresh comparison after a quiet spell instead of averaging the whole spell", async () => {
+ const { io, calls, advance } = fakeIo();
+ const sampler = createSystemSampler(io);
+ await sampler.read();
+ advance(STALE_MS + 1);
+ const before = calls.cpu;
+ await sampler.read();
+ // the first reading takes two CPU samples (baseline, then the window); a stale one does the same
+ expect(calls.cpu - before).toBe(2);
+ advance(5000);
+ const steady = calls.cpu;
+ await sampler.read();
+ expect(calls.cpu - steady).toBe(1);
+ });
+
+ it("starts a fresh comparison when cores come or go", async () => {
+ let cores = 4;
+ const { io, calls, advance } = fakeIo({ cpuCount: () => cores });
+ const sampler = createSystemSampler(io);
+ await sampler.read();
+ cores = 5;
+ advance(5000);
+ const before = calls.cpu;
+ expect((await sampler.read()).cpu_count).toBe(5);
+ expect(calls.cpu - before).toBe(2);
+ });
+
+ it("leaves a disk that does not answer out, answers anyway, and does not ask it again meanwhile", async () => {
+ let asked = 0;
+ const never = new Promise(() => {});
+ const { io, advance } = fakeIo({ disks: () => { asked += 1; return never; } });
+ const sampler = createSystemSampler(io, { diskCapMs: 20 });
+ const first = await sampler.read();
+ expect(first.disks).toEqual([]);
+ expect(first.memory).not.toBeNull();
+ advance(5000);
+ const second = await sampler.read();
+ expect(second.disks).toEqual([]);
+ // one read is still pending, so no second thread was spent on the stuck mount
+ expect(asked).toBe(1);
+ });
+
+ it("keeps the last disks while one read is late, and takes the new answer when it arrives", async () => {
+ const disk = { path: "/", used: 5, total: 10, percent: 50 };
+ let release: (list: typeof disk[]) => void = () => {};
+ let call = 0;
+ const { io, advance } = fakeIo({ disks: () => { call += 1; return call === 1 ? Promise.resolve([disk]) : new Promise((done) => { release = done; }); } });
+ const sampler = createSystemSampler(io, { diskCapMs: 20 });
+ expect((await sampler.read()).disks).toEqual([disk]);
+ advance(5000);
+ expect((await sampler.read()).disks).toEqual([disk]);
+ release([{ ...disk, percent: 60 }]);
+ await Bun.sleep(5); // fixed wait: lets the released read settle before the next ask
+ advance(5000);
+ expect((await sampler.read()).disks[0]?.percent).toBe(60);
+ });
+
+ it("serves GET as JSON that is never cached, and refuses writes", async () => {
+ const sampler = createSystemSampler(fakeIo().io);
+ const ok = await handleSystemRequest(new Request("http://x/api/system"), sampler);
+ expect(ok.status).toBe(200);
+ expect(ok.headers.get("cache-control")).toBe("no-store");
+ expect((await ok.json() as { cpu_count: number }).cpu_count).toBe(4);
+ expect((await handleSystemRequest(new Request("http://x/api/system", { method: "POST" }), sampler)).status).toBe(405);
+ });
+});
diff --git a/server/system-stats.ts b/server/system-stats.ts
new file mode 100644
index 0000000000..5911bddf5c
--- /dev/null
+++ b/server/system-stats.ts
@@ -0,0 +1,219 @@
+/**
+ * What this PC says about its own load: CPU, memory, load average and the disks that hold the user's
+ * files. It feeds the PC header in the sidebar (GET /api/system), and a remote
+ * PC answers the same route through its bridge.
+ *
+ * Nothing runs in the background. A reading is taken when a client asks, from counters the kernel
+ * keeps anyway: CPU is the change since the previous reading, so the first
+ * ask waits a moment (FIRST_WINDOW_MS) to have something to compare. Asks closer together than
+ * FRESH_MS share one reading, so several browsers or tabs never shorten each other's window.
+ *
+ * Linux is read from /proc (memory is total minus MemAvailable, so the file cache never reads as
+ * "used"). Everywhere else gets what Node's `os` can say, and the fields it cannot say are null: a
+ * number that was not measured is never shown as zero. macOS memory is one of those: `os` counts
+ * the file cache as used there, so it is left out rather than shown high.
+ *
+ * Limits worth knowing. In a container without lxcfs, /proc and `os` describe the host, not the
+ * container's cgroup limits. libuv leaves iowait, softirq and steal out of the CPU times, so a VM
+ * with a lot of steal reads a little lower than `top`. A disk that does not answer (a stuck network
+ * mount) is left out after DISK_CAP_MS and is never asked again while the first ask is pending.
+ */
+
+import { readFile, stat, statfs } from "node:fs/promises";
+import { cpus, freemem, homedir, loadavg, platform as osPlatform, totalmem, uptime } from "node:os";
+import { parse as parsePath, resolve } from "node:path";
+import type { SystemDisk, SystemReport } from "../shared/system.ts";
+import { jsonResponse } from "./http.ts";
+
+/** asks inside this window get the reading already taken */
+export const FRESH_MS = 1500;
+/** how long the first reading watches the counters before it answers */
+export const FIRST_WINDOW_MS = 250;
+/** a CPU comparison point older than this is an average of a quiet spell, not "now": start again */
+export const STALE_MS = 30_000;
+/** how long the disks may take before they are left out of a reading */
+export const DISK_CAP_MS = 1500;
+/** a filesystem smaller than this (a boot partition, a tmpfs) is not a disk that fills up */
+const MIN_DISK_BYTES = 1024 ** 3;
+const MAX_DISKS = 4;
+
+export interface CpuTimes { busy: number; total: number }
+
+/** Every read the sampler makes, so tests can stand in for the machine. */
+export interface SystemIo {
+ platform: NodeJS.Platform;
+ now(): number;
+ sleep(ms: number): Promise;
+ cpuTimes(): CpuTimes;
+ cpuCount(): number;
+ uptime(): number;
+ loadavg(): [number, number, number];
+ /** total and in-use bytes, or null when this PC cannot say */
+ memory(): Promise<{ total: number; used: number } | null>;
+ disks(): Promise;
+}
+
+/** The kernel's per-core counters added up: ticks spent on work and ticks in all. */
+export function cpuTimesOf(list: ReadonlyArray<{ times: { user: number; nice: number; sys: number; idle: number; irq: number } }>): CpuTimes {
+ let busy = 0;
+ let total = 0;
+ for (const { times } of list) {
+ const work = times.user + times.nice + times.sys + times.irq;
+ busy += work;
+ total += work + times.idle;
+ }
+ return { busy, total };
+}
+
+/** `MemTotal` and `MemAvailable` from /proc/meminfo, in bytes; null when either is missing. */
+export function parseMeminfo(text: string): { total: number; used: number } | null {
+ const kib = (name: string): number | null => {
+ const match = new RegExp(`^${name}:\\s+(\\d+)\\s*kB`, "m").exec(text);
+ return match ? Number(match[1]) * 1024 : null;
+ };
+ const total = kib("MemTotal");
+ const available = kib("MemAvailable");
+ if (total === null || available === null || total <= 0) return null;
+ return { total, used: Math.max(0, total - Math.min(available, total)) };
+}
+
+const round = (value: number, places = 1): number => Math.round(value * 10 ** places) / 10 ** places;
+
+/** One reading from two sets of counters `seconds` apart. */
+export function cpuPercent(before: CpuTimes, after: CpuTimes): number | null {
+ const total = after.total - before.total;
+ const busy = after.busy - before.busy;
+ if (!(total > 0) || busy < 0) return null;
+ return round(Math.min(100, (busy / total) * 100));
+}
+
+/** df's numbers for one filesystem; the percent is df's Use% (used over used plus what a user can still fill). */
+export function diskOf(path: string, stats: { bsize: number; blocks: number; bfree: number; bavail: number }): SystemDisk | null {
+ const used = (stats.blocks - stats.bfree) * stats.bsize;
+ const total = used + stats.bavail * stats.bsize;
+ if (!(total >= MIN_DISK_BYTES) || used < 0) return null;
+ return { path, used, total, percent: round((used / total) * 100) };
+}
+
+/**
+ * The root, then the home folder when it is on another disk: at most MAX_DISKS, each filesystem
+ * once. Two btrfs subvolumes have different device numbers but one pool, so the numbers decide too.
+ */
+async function readDisks(): Promise {
+ const root = parsePath(resolve(homedir())).root || "/";
+ const devices = new Set();
+ const shapes = new Set();
+ const disks: SystemDisk[] = [];
+ for (const path of [root, homedir()]) {
+ try {
+ const { dev } = await stat(path);
+ if (devices.has(dev)) continue;
+ const numbers = await statfs(path);
+ const shape = `${numbers.bsize}:${numbers.blocks}:${numbers.bfree}:${numbers.bavail}`;
+ if (shapes.has(shape)) continue;
+ const disk = diskOf(path, numbers);
+ if (disk === null) continue;
+ devices.add(dev);
+ shapes.add(shape);
+ disks.push(disk);
+ } catch { /* a disk that does not answer is left out; the others still show */ }
+ }
+ return disks.slice(0, MAX_DISKS);
+}
+
+export const realIo: SystemIo = {
+ platform: osPlatform(),
+ now: () => Date.now(),
+ // fixed wait: CPU is the change between two readings, so the first one needs a window
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
+ cpuTimes: () => cpuTimesOf(cpus()),
+ cpuCount: () => cpus().length,
+ uptime: () => uptime(),
+ loadavg: () => loadavg() as [number, number, number],
+ async memory() {
+ if (osPlatform() === "linux") {
+ try { return parseMeminfo(await readFile("/proc/meminfo", "utf8")); } catch { return null; }
+ }
+ // `os` counts the file cache as used on macOS, which would read as a full PC: left out there
+ if (osPlatform() !== "win32") return null;
+ const total = totalmem();
+ return total > 0 ? { total, used: Math.max(0, total - freemem()) } : null;
+ },
+ disks: readDisks,
+};
+
+export interface SystemSampler { read(): Promise }
+
+/** `work`, or `fallback` once `ms` have passed: the work is left to finish on its own, never awaited again. */
+function within(work: Promise, ms: number, fallback: () => T): Promise {
+ return new Promise((done) => {
+ // fixed wait: a deadline raced against the work, which a stuck mount may never finish
+ const timer = setTimeout(() => done(fallback()), ms);
+ work.then((value) => { clearTimeout(timer); done(value); }, () => { clearTimeout(timer); done(fallback()); });
+ });
+}
+
+export function createSystemSampler(io: SystemIo = realIo, options: { diskCapMs?: number } = {}): SystemSampler {
+ const diskCapMs = options.diskCapMs ?? DISK_CAP_MS;
+ let previous: { at: number; cpu: CpuTimes; cores: number } | null = null;
+ let last: SystemReport | null = null;
+ let inflight: Promise | null = null;
+ let lastDisks: SystemDisk[] = [];
+ let diskRead: Promise | null = null;
+
+ /**
+ * The disks, within diskCapMs. A disk read that has not come back is not started again: each
+ * one holds a thread of the process's small pool, and a stuck mount would use them all up. The
+ * last answer stands in until it does.
+ */
+ function disks(): Promise {
+ diskRead ??= io.disks().then((list) => { lastDisks = list; return list; }).finally(() => { diskRead = null; });
+ return within(diskRead, diskCapMs, () => lastDisks);
+ }
+
+ async function take(): Promise {
+ const cores = io.cpuCount();
+ // nothing to compare with, a comparison point from a quiet spell (that would be an average of
+ // the whole spell, not "now"), or cores that came or went (their lifetime of ticks joins one
+ // reading): compare over a fresh window
+ if (previous === null || io.now() - previous.at > STALE_MS || previous.cores !== cores) {
+ previous = { at: io.now(), cpu: io.cpuTimes(), cores };
+ await io.sleep(FIRST_WINDOW_MS);
+ }
+ const before = previous;
+ const at = io.now();
+ const cpu = io.cpuTimes();
+ const [memory, list] = await Promise.all([io.memory(), disks()]);
+ previous = { at, cpu, cores };
+ const win = io.platform === "win32";
+ const load = io.loadavg();
+ return {
+ sampled_at: at,
+ platform: io.platform === "linux" || io.platform === "darwin" || io.platform === "win32" ? io.platform : "other",
+ uptime_s: Math.round(io.uptime()),
+ cpu_count: cores,
+ cpu_percent: cpuPercent(before.cpu, cpu),
+ memory: memory === null ? null : { used: memory.used, total: memory.total, percent: round((memory.used / memory.total) * 100) },
+ load: win ? null : [round(load[0], 2), round(load[1], 2), round(load[2], 2)],
+ disks: list,
+ };
+ }
+
+ return {
+ async read() {
+ if (last !== null && io.now() - last.sampled_at < FRESH_MS) return last;
+ inflight ??= take().then((report) => { last = report; return report; }).finally(() => { inflight = null; });
+ return inflight;
+ },
+ };
+}
+
+/** GET /api/system. Access is decided before this point (index.ts), as for every route. */
+export async function handleSystemRequest(request: Request, sampler: SystemSampler): Promise {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return jsonResponse({ error: { code: "method_not_allowed", message: "Use GET" } }, 405);
+ }
+ const response = jsonResponse(await sampler.read());
+ response.headers.set("cache-control", "no-store");
+ return response;
+}
diff --git a/shared/system.ts b/shared/system.ts
new file mode 100644
index 0000000000..1b54e237f3
--- /dev/null
+++ b/shared/system.ts
@@ -0,0 +1,31 @@
+/**
+ * The wire shape of GET /api/system: what one PC says about its own load right now.
+ * Types only (src/ imports nothing else from here); the numbers are read by server/system-stats.ts.
+ */
+
+/** One filesystem the user's files live on, with the numbers `df` prints for it. */
+export interface SystemDisk {
+ /** the mount the reading was taken at: `/`, or the home folder when it is on a disk of its own */
+ path: string;
+ /** bytes in use (df's Used) */
+ used: number;
+ /** bytes a normal user can fill: used plus what is still free to them, so a little under df's Size */
+ total: number;
+ /** df's Use%, from the same two numbers */
+ percent: number;
+}
+
+export interface SystemReport {
+ /** epoch ms of the reading; a client that holds an answer longer than a minute should call it old */
+ sampled_at: number;
+ platform: "linux" | "darwin" | "win32" | "other";
+ uptime_s: number;
+ cpu_count: number;
+ /** the share of CPU time in use since the previous reading; null when there is nothing to compare yet */
+ cpu_percent: number | null;
+ /** memory in use without the file cache (Linux: total minus MemAvailable) */
+ memory: { used: number; total: number; percent: number } | null;
+ /** 1, 5 and 15 minute load averages; null on Windows, which has none */
+ load: [number, number, number] | null;
+ disks: SystemDisk[];
+}
diff --git a/site/demo/transport.ts b/site/demo/transport.ts
index 0cd92d4e64..4773cfde24 100644
--- a/site/demo/transport.ts
+++ b/site/demo/transport.ts
@@ -19,6 +19,7 @@ import type { AgentIntegration, AgentStatus, CommandForm, CommandOutcome, Conver
import type { PushSubscriptionStatus, RepoBranchComparison, RepoDiffFile, RepoDiffFileList, RepoDiffFilePatch, RepoDiffRefs, RepoDiffRepository, RepoDiffScope, SessionHistoryAgent, SessionHistoryResponse, SessionResumeResponse } from "../../shared/protocol.ts";
import { nextTabLabels, normalizeThemeChange, sharedPreferencesBody, type SharedPreferences } from "../../shared/preferences.ts";
import type { PullRequestCheckLog, PullRequestView } from "../../shared/pull-request.ts";
+import type { SystemReport } from "../../shared/system.ts";
import { VOICE_DEFAULTS, type VoiceStatus } from "../../shared/voice.ts";
import { ALERT_WEBHOOK_PRESETS, type AlertWebhookSave, type AlertWebhookView } from "../../shared/alert-webhook.ts";
import { DEFAULT_ALERTS } from "../../shared/notify-policy.ts";
@@ -1069,6 +1070,17 @@ let demoStatusline = false;
const removedUsageAccounts = new Set();
const USAGE_STATE = new URLSearchParams(location.search).get("demo_usage");
+/** A calm PC, a little different each time it is asked, for the sidebar's CPU, memory and disk meters. */
+function systemReport(): SystemReport {
+ const wobble = (base: number, spread: number): number => Math.round((base + (Math.random() - 0.5) * spread) * 10) / 10;
+ const gib = 1024 ** 3;
+ return {
+ sampled_at: Date.now(), platform: "linux", uptime_s: 3 * 86400 + 5 * 3600, cpu_count: 8,
+ cpu_percent: wobble(24, 16), memory: { used: 11.2 * gib, total: 32 * gib, percent: 35 },
+ load: [0.86, 0.72, 0.64], disks: [{ path: "/", used: 412 * gib, total: 931 * gib, percent: 44.3 }],
+ };
+}
+
/** Settings → Agents' accounts on the demo: plans as the workstation's CLIs might report them, resets counted from now. */
/**
* `?demo_sign_in=1`: the in-browser sign-in plays the flows recorded from Claude Code 2.1.312 and
@@ -1365,6 +1377,7 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in
if (path === "/api/portal/start" || path === "/api/portal/stop" || path === "/api/portal/invite") return error("portal_unsupported", "The demo opens no public address.", 409);
if (path === "/api/herdr-ui") return json({ copy_on_select: true }, 200, { "cache-control": "no-store" });
if (path === "/api/access") return json({ port: 7317, relay: demoRelayStatus(), tailscale: { state: "running", dns_name: "workstation.example.ts.net", serving_url: "https://workstation.example.ts.net", serve_command: null, serve_url: null } });
+ if (path === "/api/system") return json(systemReport(), 200, { "cache-control": "no-store" });
if (path === "/api/usage") {
const report = usageReport();
const removed = report.providers.filter((account) => removedUsageAccounts.has(account.key));
diff --git a/src/components/MachineSidebar.tsx b/src/components/MachineSidebar.tsx
index 1d851d7706..88ff7c1da4 100644
--- a/src/components/MachineSidebar.tsx
+++ b/src/components/MachineSidebar.tsx
@@ -8,6 +8,7 @@ import { describeProgress } from "../lib/bridgeProgress.ts";
import { keepDismissed, noticeKey, readDismissed, waitingMachines, writeDismissed } from "../lib/machineNotice.ts";
import type { AppActions } from "../lib/actions.ts";
import { Sidebar } from "./Sidebar.tsx";
+import { MachineStats } from "./MachineStats.tsx";
import { AgentSidebar } from "./AgentSidebar.tsx";
import { SidebarSplit } from "./SidebarSplit.tsx";
import { storedSidebarSplit, storeSidebarSplit, type SplitFold, type SplitOrder } from "../lib/sidebarSplit.ts";
@@ -354,6 +355,7 @@ function MachineGroup({ machine, headerless = false, ...props }: Props & { machi
{!noticed && machine.error && {machineErrorText(machine.error, t)}}
{nextStep !== null && {nextStep}}
;
+}
+
+/** "CPU 4%, Memory 22%, Disk 50%": what the button says aloud, since its meters are only drawn. */
+function spoken(t: Translate, report: SystemReport): string {
+ const disk = fullestDisk(report.disks);
+ return [
+ t("CPU {value}", { value: report.cpu_percent === null ? t("Not measured") : formatPercentValue(report.cpu_percent) }),
+ ...(report.memory ? [t("Memory {value}", { value: formatPercentValue(report.memory.percent) })] : []),
+ ...(disk ? [t("Disk {value}", { value: formatPercentValue(disk.percent) })] : []),
+ ].join(", ");
+}
+
+/**
+ * The load of a PC under its name: CPU, memory and its fullest disk as three small meters, and
+ * on a tap the numbers behind them. It asks only while it is on screen (lib/system.ts), and
+ * draws nothing for a PC that cannot say.
+ */
+export function MachineStats({ machine }: { machine: Machine }) {
+ const t = useT();
+ const id = useId();
+ const [open, setOpen] = useState(() => { try { return localStorage.getItem(`ovis:pc-stats:${machine.id}`) === "1"; } catch { return false; } });
+ const { report, unavailable } = useSystemStats(machine.id, true, `${machine.state}:${machine.source_boot_id ?? ""}:${machine.herdr?.version ?? ""}`);
+ if (unavailable || report === null) return null;
+ const disk = fullestDisk(report.disks);
+ const toggle = () => {
+ setOpen(!open);
+ try { localStorage.setItem(`ovis:pc-stats:${machine.id}`, open ? "0" : "1"); } catch {}
+ };
+ return
+
+ {open &&
}
+
;
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index d847416f70..2737d100fb 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -6,6 +6,8 @@ import type { AgentCatalogResponse, AgentTabStarted } from "../../shared/agent-c
import type { ConversationSearchResponse } from "../../shared/conversation-search.ts";
import { resumeConnectionTraces } from "./connectionTrace.ts";
import type { PullRequestCheckLog, PullRequestMergeRequest, PullRequestView } from "../../shared/pull-request.ts";
+import type { SystemReport } from "../../shared/system.ts";
+import { parseSystemReport } from "./systemReport.ts";
import { machinePath, type BridgeHealth, type HerdrCoreFacts, type HerdrIdentity, type HerdrRuntimeFacts, type Machine, type MachineRoster, type SetupAction, type SetupJob, type SetupRequest } from "../../shared/machines.ts";
import type {
WorkspaceMoved,
@@ -405,6 +407,14 @@ async function getJson(url: string, init?: RequestInit): Promise {
return (await response.json()) as T;
}
+/** GET /api/system on a PC: its load right now. A bridge from before this route answers 404. */
+export async function fetchSystem(machineId = "local", signal?: AbortSignal): Promise {
+ const report = parseSystemReport(await getJson(machinePath(machineId, "system"), { cache: "no-store", ...(signal ? { signal } : {}) }));
+ // another PC's bridge can be of another version: an answer the sidebar cannot draw is an unusable one
+ if (report === null) throw new ApiError(machinePath(machineId, "system"), 502, UNUSABLE_ANSWER["remote_response_invalid"]!(), "remote_response_invalid");
+ return report;
+}
+
export async function fetchSession(machineId = "local"): Promise {
const body = await getJson<{ snapshot: SessionSnapshot }>(machinePath(machineId, "session"));
return body.snapshot;
diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts
index 4feed00bb7..40b955b5e9 100644
--- a/src/lib/i18n.test.ts
+++ b/src/lib/i18n.test.ts
@@ -111,7 +111,7 @@ describe.each(Object.entries(DICTIONARIES))("%s dictionary", (_name, dictionary)
// theme brand names stay as their projects write them (the color words Amber and Charcoal are translated),
// and so does Claude Code's own term Ultracode
const themeNames = ["Codex", "Catppuccin", "Catppuccin Latte", "Tokyo Night", "Tokyo Night Day", "Gruvbox", "Gruvbox Light", "One Dark", "One Light", "Solarized", "Solarized Light", "Kanagawa", "Kanagawa Lotus", "Rosé Pine", "Rosé Pine Dawn", "Dracula", "Nord", "Vesper"];
- const sameOnPurpose = new Set(["PC {name}", "Control C", "Control D", "Control Z", "Shift Tab", "Enter", "Shell", "Agent", "Ultracode", ...themeNames]);
+ const sameOnPurpose = new Set(["PC {name}", "CPU", "CPU {value}", "Control C", "Control D", "Control Z", "Shift Tab", "Enter", "Shell", "Agent", "Ultracode", ...themeNames]);
const same = Object.entries(dictionary).filter(([en, text]) => en === text && /[a-z]{3}/i.test(en) && !sameOnPurpose.has(en));
expect(same.map(([en]) => en)).toEqual([]);
});
@@ -124,7 +124,7 @@ const PART_FILES: Record = {
composer: ["components/Composer.tsx", "components/ReplyComments.tsx", "components/ComposerNotices.tsx", "components/VoiceInput.tsx", "components/SecretInput.tsx", "components/TerminalInput.tsx", "components/PendingMessages.tsx", "lib/compose.ts", "components/PromptCard.tsx", "lib/promptAnswer.ts", "../shared/card-text.ts"],
chat: ["components/ChatView.tsx", "lib/chatImages.ts", "components/SentWaiting.tsx", "components/Markdown.tsx", "components/MermaidLightbox.tsx", "components/ChatMinimap.tsx", "components/ImageLightbox.tsx", "components/BackgroundTasks.tsx", "components/BackgroundAgentDetail.tsx", "components/Artifacts.tsx", "components/OmpBtw.tsx", "components/MarkdownLinkMenu.tsx", "lib/workBlocks.ts", "lib/toolVerbs.ts", "lib/outcomeChip.ts", "lib/chatTimestamp.ts", "lib/chatReadDeadline.ts", "components/ConversationSearch.tsx"],
sidebar: ["components/Sidebar.tsx", "components/AgentSidebar.tsx", "components/SidebarResizer.tsx", "components/SidebarSplit.tsx"],
- machines: ["components/MachineSidebar.tsx", "components/MachineDialog.tsx", "lib/bridgeProgress.ts"],
+ machines: ["components/MachineSidebar.tsx", "components/MachineDialog.tsx", "lib/bridgeProgress.ts", "components/MachineStats.tsx", "lib/systemReport.ts"],
workspace: ["components/NewSessionDialog.tsx", "components/WorktreeDialog.tsx", "components/DirectoryBrowser.tsx", "components/RecentFolderChips.tsx", "components/SessionHistory.tsx", "lib/sessionHistory.ts", "lib/useAgentSkills.ts"],
files: ["components/FileViewer.tsx", "components/RelayRenderedPage.tsx", "components/FilesDialog.tsx", "components/FileReader.tsx", "components/TextFileView.tsx", "components/FilePathLink.tsx", "components/FilePanelResizer.tsx", "components/HighlightedCode.tsx", "components/RepoDiffViewer.tsx", "components/RepoChanges.tsx", "components/RepoDiff.tsx", "lib/repoDiff.ts", "lib/diffLineNotes.ts", "components/WebPanel.tsx", "components/PullRequestPanel.tsx", "lib/pullRequest.ts", "components/FileTextSizeRow.tsx", "components/FilesPanelRow.tsx"],
devices: ["components/DevicesPanel.tsx", "components/ConnectPhoneDialog.tsx", "components/PhonePanel.tsx", "components/PortalPanel.tsx", "components/RelayPanel.tsx", "components/PortalAuth.tsx", "components/PortalInvite.tsx", "components/PushTestControls.tsx", "components/AccessGate.tsx", "components/LanExposureNotice.tsx", "components/DevicePairedNotice.tsx", "components/InstallHint.tsx", "lib/install.ts", "lib/installHint.ts", "lib/portalAuth.ts", "../shared/portal.ts"],
diff --git a/src/lib/i18n/machines.ja.ts b/src/lib/i18n/machines.ja.ts
index 52d18a5e73..7185d9f10b 100644
--- a/src/lib/i18n/machines.ja.ts
+++ b/src/lib/i18n/machines.ja.ts
@@ -1,7 +1,7 @@
/**
* Japanese for the keys only the machines files use:
* components/MachineSidebar.tsx (with STATE_WORD), components/MachineDialog.tsx,
- * lib/bridgeProgress.ts (with STAGES).
+ * lib/bridgeProgress.ts (with STAGES), components/MachineStats.tsx, lib/systemReport.ts.
* Register and fixed terms follow ../i18n.ja.ts; a key another feature also uses goes there.
*/
export const MACHINES_JA: Record = {
@@ -121,4 +121,21 @@ export const MACHINES_JA: Record = {
"Updating {name}'s own Ovis": "{name} の Ovis を更新しています",
"That PC's own updater installs it there. Existing herdr sessions keep running.": "その PC 自身のアップデーターがそこにインストールします。既存の herdr セッションは動き続けます。",
"Removes this PC here. To end its access too, revoke it under Phone & devices on that PC.": "この PC からだけ削除します。アクセスも止めるには、その PC の スマートフォンとデバイス で取り消してください。",
+ "CPU": "CPU",
+ "Memory": "メモリ",
+ "Disk": "ディスク",
+ "Disks": "ディスク",
+ "Load": "負荷",
+ "Uptime": "稼働時間",
+ "{count} cores": "{count} コア",
+ "Load average over 1, 5 and 15 minutes": "1分・5分・15分の負荷平均",
+ "Show system details for {name}": "{name} のシステム詳細を表示",
+ "Hide system details for {name}": "{name} のシステム詳細を隠す",
+ "Not measured": "未計測",
+ "{days}d {hours}h": "{days}日 {hours}時間",
+ "{hours}h {minutes}m": "{hours}時間 {minutes}分",
+ "{minutes}m": "{minutes}分",
+ "CPU {value}": "CPU {value}",
+ "Memory {value}": "メモリ {value}",
+ "Disk {value}": "ディスク {value}",
};
diff --git a/src/lib/i18n/machines.ko.ts b/src/lib/i18n/machines.ko.ts
index 697dc15171..955185f445 100644
--- a/src/lib/i18n/machines.ko.ts
+++ b/src/lib/i18n/machines.ko.ts
@@ -1,7 +1,7 @@
/**
* Korean for the keys only the machines files use:
* components/MachineSidebar.tsx (with STATE_WORD), components/MachineDialog.tsx,
- * lib/bridgeProgress.ts (with STAGES).
+ * lib/bridgeProgress.ts (with STAGES), components/MachineStats.tsx, lib/systemReport.ts.
* Register and fixed terms follow ../i18n.ko.ts; a key another feature also uses goes there.
*/
export const MACHINES_KO: Record = {
@@ -121,4 +121,21 @@ export const MACHINES_KO: Record = {
"Updating {name}'s own Ovis": "{name}의 Ovis 업데이트 중",
"That PC's own updater installs it there. Existing herdr sessions keep running.": "그 PC의 자체 업데이터가 그곳에 설치합니다. 기존 herdr 세션은 계속 돕니다.",
"Removes this PC here. To end its access too, revoke it under Phone & devices on that PC.": "이 PC에서만 지웁니다. 접근까지 끊으려면 그 PC의 폰 및 기기에서 취소하세요.",
+ "CPU": "CPU",
+ "Memory": "메모리",
+ "Disk": "디스크",
+ "Disks": "디스크",
+ "Load": "부하",
+ "Uptime": "가동 시간",
+ "{count} cores": "코어 {count}개",
+ "Load average over 1, 5 and 15 minutes": "1분·5분·15분 부하 평균",
+ "Show system details for {name}": "{name}의 시스템 상세 보기",
+ "Hide system details for {name}": "{name}의 시스템 상세 숨기기",
+ "Not measured": "측정 안 됨",
+ "{days}d {hours}h": "{days}일 {hours}시간",
+ "{hours}h {minutes}m": "{hours}시간 {minutes}분",
+ "{minutes}m": "{minutes}분",
+ "CPU {value}": "CPU {value}",
+ "Memory {value}": "메모리 {value}",
+ "Disk {value}": "디스크 {value}",
};
diff --git a/src/lib/i18n/machines.zh.ts b/src/lib/i18n/machines.zh.ts
index e9d7a80c59..d8b44171e0 100644
--- a/src/lib/i18n/machines.zh.ts
+++ b/src/lib/i18n/machines.zh.ts
@@ -1,7 +1,7 @@
/**
* Simplified Chinese for the keys only the machines files use:
* components/MachineSidebar.tsx (with STATE_WORD), components/MachineDialog.tsx,
- * lib/bridgeProgress.ts (with STAGES).
+ * lib/bridgeProgress.ts (with STAGES), components/MachineStats.tsx, lib/systemReport.ts.
* Register and fixed terms follow ../i18n.zh.ts; a key another feature also uses goes there.
*/
export const MACHINES_ZH: Record = {
@@ -121,4 +121,21 @@ export const MACHINES_ZH: Record = {
"Updating {name}'s own Ovis": "正在更新 {name} 的 Ovis",
"That PC's own updater installs it there. Existing herdr sessions keep running.": "由那台 PC 自己的更新程序在那里安装。现有的 herdr 会话继续运行。",
"Removes this PC here. To end its access too, revoke it under Phone & devices on that PC.": "仅从这台 PC 移除。要同时取消它的访问权限,请在那台 PC 的 手机和设备 中撤销。",
+ "CPU": "CPU",
+ "Memory": "内存",
+ "Disk": "磁盘",
+ "Disks": "磁盘",
+ "Load": "负载",
+ "Uptime": "运行时间",
+ "{count} cores": "{count} 核",
+ "Load average over 1, 5 and 15 minutes": "1、5、15 分钟平均负载",
+ "Show system details for {name}": "显示 {name} 的系统详情",
+ "Hide system details for {name}": "隐藏 {name} 的系统详情",
+ "Not measured": "未测量",
+ "{days}d {hours}h": "{days} 天 {hours} 小时",
+ "{hours}h {minutes}m": "{hours} 小时 {minutes} 分",
+ "{minutes}m": "{minutes} 分",
+ "CPU {value}": "CPU {value}",
+ "Memory {value}": "内存 {value}",
+ "Disk {value}": "磁盘 {value}",
};
diff --git a/src/lib/system.test.ts b/src/lib/system.test.ts
new file mode 100644
index 0000000000..8ab95b5a6c
--- /dev/null
+++ b/src/lib/system.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "bun:test";
+import { ApiError } from "./api.ts";
+import { systemUnavailable } from "./system.ts";
+
+const refused = (status: number) => new ApiError("/x", status, "no", null);
+
+describe("systemUnavailable", () => {
+ it("latches a bridge from before the route (404, 405) for any PC", () => {
+ expect(systemUnavailable(refused(404), "local")).toBe(true);
+ expect(systemUnavailable(refused(405), "pc-1")).toBe(true);
+ });
+
+ it("latches a refusal through the PC proxy (an old bridge with a token set answers 401/403)", () => {
+ expect(systemUnavailable(refused(401), "pc-1")).toBe(true);
+ expect(systemUnavailable(refused(403), "pc-1")).toBe(true);
+ });
+
+ it("does not stop asking the PC this browser is signed in to because it was signed out", () => {
+ expect(systemUnavailable(refused(401), "local")).toBe(false);
+ });
+
+ it("keeps asking after a restart, a timeout or an unusable answer", () => {
+ expect(systemUnavailable(refused(502), "pc-1")).toBe(false);
+ expect(systemUnavailable(refused(503), "pc-1")).toBe(false);
+ expect(systemUnavailable(new DOMException("timeout", "TimeoutError"), "pc-1")).toBe(false);
+ expect(systemUnavailable(new TypeError("fetch failed"), "pc-1")).toBe(false);
+ });
+});
diff --git a/src/lib/system.ts b/src/lib/system.ts
new file mode 100644
index 0000000000..6ee4af78be
--- /dev/null
+++ b/src/lib/system.ts
@@ -0,0 +1,67 @@
+import { useEffect, useRef, useState } from "react";
+import type { SystemReport } from "../../shared/system.ts";
+import { LOCAL_MACHINE } from "../../shared/machines.ts";
+import { ApiError, fetchSystem } from "./api.ts";
+import { usePageVisible } from "./visibility.ts";
+
+/** the pause between one answer and the next ask for a PC that is on screen; the server shares one reading between asks closer than 1.5 s */
+export const SYSTEM_POLL_MS = 5000;
+/** an ask that has not come back by now is given up, so a stuck PC never has more than one open */
+export const SYSTEM_ASK_TIMEOUT_MS = 4000;
+/**
+ * This PC cannot say, and asking again will not change that until its bridge does. A bridge from
+ * before the route answers 404; one that has a browser token set refuses what its allowlist does
+ * not know with 401 or 403. For the PC this browser talks to itself, 401 means signed out, which
+ * is not a reason to stop asking.
+ */
+export function systemUnavailable(error: unknown, machineId: string): boolean {
+ if (!(error instanceof ApiError)) return false;
+ if (error.status === 404 || error.status === 405) return true;
+ return machineId !== LOCAL_MACHINE && (error.status === 401 || error.status === 403);
+}
+
+export interface SystemState {
+ report: SystemReport | null;
+ /** the PC cannot say (a bridge from before this route): the sidebar shows nothing for it */
+ unavailable: boolean;
+}
+
+/**
+ * The load of one PC, while `enabled` and the page is on screen. The next ask starts
+ * SYSTEM_POLL_MS after the last one came back or timed out, so a stuck PC holds one request open,
+ * never a queue of them. Nothing is
+ * asked for a PC that is folded, offline or in a background tab. A PC whose bridge cannot answer
+ * is not asked again until `retryKey` changes (its bridge was updated, or it reconnected).
+ */
+export function useSystemStats(machineId: string, enabled: boolean, retryKey: string): SystemState {
+ const [state, setState] = useState({ report: null, unavailable: false });
+ const visible = usePageVisible();
+ const missing = useRef(null);
+
+ useEffect(() => {
+ if (!enabled || !visible || missing.current === retryKey) return;
+ const abort = new AbortController();
+ let timer: number | undefined;
+ const load = async () => {
+ try {
+ const report = await fetchSystem(machineId, AbortSignal.any([abort.signal, AbortSignal.timeout(SYSTEM_ASK_TIMEOUT_MS)]));
+ if (abort.signal.aborted) return;
+ missing.current = null;
+ setState({ report, unavailable: false });
+ } catch (error) {
+ if (abort.signal.aborted) return;
+ if (systemUnavailable(error, machineId)) {
+ missing.current = retryKey;
+ setState({ report: null, unavailable: true });
+ return;
+ }
+ // anything else (offline, restarting, a timeout): the last reading stays until the next ask
+ }
+ timer = window.setTimeout(() => void load(), SYSTEM_POLL_MS);
+ };
+ void load();
+ return () => { abort.abort(); window.clearTimeout(timer); };
+ }, [machineId, enabled, visible, retryKey]);
+
+ return state;
+}
diff --git a/src/lib/systemReport.test.ts b/src/lib/systemReport.test.ts
new file mode 100644
index 0000000000..5ac9b927a0
--- /dev/null
+++ b/src/lib/systemReport.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "bun:test";
+import type { SystemReport } from "../../shared/system.ts";
+import { translate } from "./i18n.ts";
+import { formatBytes, formatPercentValue, formatUptime, fullestDisk, parseSystemReport } from "./systemReport.ts";
+
+const good: SystemReport = {
+ sampled_at: 1, platform: "linux", uptime_s: 100, cpu_count: 4, cpu_percent: 12.5,
+ memory: { used: 1, total: 2, percent: 50 }, load: [0.1, 0.2, 0.3],
+ disks: [{ path: "/", used: 1, total: 2, percent: 50 }],
+};
+
+describe("system formatting", () => {
+ it("writes bytes in powers of 1024", () => {
+ expect(formatBytes(0)).toBe("0 B");
+ expect(formatBytes(1536)).toBe("1.5 KB");
+ expect(formatBytes(15 * 1024 ** 2)).toBe("15 MB");
+ expect(formatBytes(2.5 * 1024 ** 4)).toBe("2.5 TB");
+ });
+
+ it("writes uptime in its two largest units, in the UI language", () => {
+ const en = (key: string, vars?: Record) => translate("en", key, vars);
+ const ko = (key: string, vars?: Record) => translate("ko", key, vars);
+ expect(formatUptime(59, en)).toBe("0m");
+ expect(formatUptime(3 * 3600 + 5 * 60, en)).toBe("3h 5m");
+ expect(formatUptime(2 * 86400 + 3 * 3600 + 900, en)).toBe("2d 3h");
+ expect(formatUptime(2 * 86400 + 3 * 3600, ko)).toBe("2일 3시간");
+ });
+
+ it("shows a missing percent as a dash, not 0%", () => {
+ expect(formatPercentValue(null)).toBe("—");
+ expect(formatPercentValue(41.6)).toBe("42%");
+ });
+
+ it("picks the fullest disk", () => {
+ expect(fullestDisk([])).toBeNull();
+ const disks = [{ path: "/", used: 1, total: 2, percent: 50 }, { path: "/home", used: 9, total: 10, percent: 90 }];
+ expect(fullestDisk(disks)?.path).toBe("/home");
+ });
+});
+
+describe("parseSystemReport", () => {
+ it("accepts what the server sends, with the nullable fields null", () => {
+ expect(parseSystemReport(good)).toEqual(good);
+ expect(parseSystemReport({ ...good, cpu_percent: null, memory: null, load: null, disks: [] })).not.toBeNull();
+ });
+
+ it("refuses an answer the sidebar could not draw, instead of throwing later", () => {
+ for (const bad of [null, "x", [], {}, { ...good, disks: null }, { ...good, load: "x" }, { ...good, load: [1, 2] }, { ...good, memory: { used: "a", total: 2, percent: 1 } },
+ { ...good, cpu_percent: 140 }, { ...good, cpu_percent: Number.NaN }, { ...good, cpu_count: 0 }, { ...good, disks: [{ path: 1, used: 1, total: 2, percent: 1 }] }]) {
+ expect(parseSystemReport(bad)).toBeNull();
+ }
+ });
+
+ it("cuts a list longer than a PC can have and names an unknown platform other", () => {
+ const disks = Array.from({ length: 9 }, (_, i) => ({ path: `/d${i}`, used: 1, total: 2, percent: 50 }));
+ expect(parseSystemReport({ ...good, disks })?.disks).toHaveLength(4);
+ expect(parseSystemReport({ ...good, platform: "plan9" })?.platform).toBe("other");
+ });
+});
diff --git a/src/lib/systemReport.ts b/src/lib/systemReport.ts
new file mode 100644
index 0000000000..da0e542af2
--- /dev/null
+++ b/src/lib/systemReport.ts
@@ -0,0 +1,69 @@
+import type { SystemDisk, SystemReport } from "../../shared/system.ts";
+import type { Translate } from "./i18n.ts";
+
+/** CPU, memory or disk at or above this reads as high */
+export const SYSTEM_HIGH_PERCENT = 85;
+
+/** The fullest disk is the one that can stop work; the sidebar shows that one. */
+export function fullestDisk(disks: readonly SystemDisk[]): SystemDisk | null {
+ return disks.reduce((fullest, disk) => fullest === null || disk.percent > fullest.percent ? disk : fullest, null);
+}
+
+const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
+/** 1536 -> "1.5 KB"; one decimal below 10, none above; powers of 1024, as `df -h` writes them. */
+export function formatBytes(bytes: number): string {
+ let value = Math.max(0, bytes);
+ let unit = 0;
+ while (value >= 1024 && unit < UNITS.length - 1) { value /= 1024; unit += 1; }
+ const text = unit === 0 || value >= 10 ? String(Math.round(value)) : value.toFixed(1);
+ return `${text} ${UNITS[unit]}`;
+}
+
+/** Uptime in its two largest units; the unit letters are the UI language's. */
+export function formatUptime(seconds: number, t: Translate): string {
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return days > 0 ? t("{days}d {hours}h", { days, hours }) : hours > 0 ? t("{hours}h {minutes}m", { hours, minutes }) : t("{minutes}m", { minutes });
+}
+
+export function formatPercentValue(percent: number | null): string {
+ return percent === null ? "—" : `${Math.round(percent)}%`;
+}
+
+const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
+const percentOf = (value: unknown): value is number => finite(value) && value >= 0 && value <= 100;
+const MAX_DISKS = 4;
+
+/**
+ * What another PC's bridge said, checked: a bridge of another version, or a broken one, must not
+ * be able to throw while the sidebar draws. Null when anything the UI reads is missing or of the
+ * wrong kind; a list longer than a PC can have is cut.
+ */
+export function parseSystemReport(value: unknown): SystemReport | null {
+ if (typeof value !== "object" || value === null) return null;
+ const r = value as Record;
+ if (!finite(r["sampled_at"]) || !finite(r["uptime_s"]) || !finite(r["cpu_count"]) || r["cpu_count"] < 1) return null;
+ if (r["cpu_percent"] !== null && !percentOf(r["cpu_percent"])) return null;
+ const memory = r["memory"];
+ if (memory !== null) {
+ const m = memory as Record | undefined;
+ if (typeof m !== "object" || !finite(m["used"]) || !finite(m["total"]) || !percentOf(m["percent"])) return null;
+ }
+ const load = r["load"];
+ if (load !== null && !(Array.isArray(load) && load.length === 3 && load.every(finite))) return null;
+ if (!Array.isArray(r["disks"])) return null;
+ const disks: SystemDisk[] = [];
+ for (const item of r["disks"].slice(0, MAX_DISKS)) {
+ const d = item as Record | null;
+ if (typeof d !== "object" || d === null || typeof d["path"] !== "string" || !finite(d["used"]) || !finite(d["total"]) || !percentOf(d["percent"])) return null;
+ disks.push({ path: d["path"], used: d["used"], total: d["total"], percent: d["percent"] });
+ }
+ const platform = r["platform"];
+ return {
+ sampled_at: r["sampled_at"], uptime_s: r["uptime_s"], cpu_count: r["cpu_count"], cpu_percent: r["cpu_percent"] as number | null,
+ platform: platform === "linux" || platform === "darwin" || platform === "win32" ? platform : "other",
+ memory: memory as SystemReport["memory"], load: load as SystemReport["load"], disks,
+ };
+}
+
From 5163f594de97098c9ac3c64282649f6b721d94a3 Mon Sep 17 00:00:00 2001
From: thefahien <333489353+thefahien@users.noreply.github.com>
Date: Thu, 5 Nov 2026 16:34:35 +0000
Subject: [PATCH 2/6] =?UTF-8?q?feat(sidebar):=20turn=20the=20PC=20load=20l?=
=?UTF-8?q?ine=20on=20or=20off=20in=20Settings=20=E2=86=92=20Remote=20PCs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
DESIGN.md | 5 ++++-
changelog.d/pc-system-load.md | 4 +++-
docs/guide.md | 2 +-
src/App.tsx | 4 ++--
src/components/MachineSidebar.tsx | 4 ++--
src/components/MachineStats.tsx | 14 ++++++++++++++
src/components/SettingsDialog.tsx | 2 ++
src/lib/i18n/machines.ja.ts | 2 ++
src/lib/i18n/machines.ko.ts | 2 ++
src/lib/i18n/machines.zh.ts | 2 ++
src/lib/settings.legacy-fixture.ts | 2 +-
src/lib/settings.test.ts | 17 ++++++++++++++++-
src/lib/settings.ts | 16 ++++++++++++++--
13 files changed, 65 insertions(+), 11 deletions(-)
diff --git a/DESIGN.md b/DESIGN.md
index 5f58564f3f..697cf57dfa 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1584,6 +1584,9 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
note is dim, red for an expired sign-in or a failed request.
### PC load (`.machine-stats`)
+- Shown while Settings → Remote PCs → **PC load in the sidebar** is on, for this device only. Until
+ it is flipped it follows the roster: on while two or more PCs are connected, off for a lone PC,
+ whose load is not what tells PCs apart.
- Under an expanded, connected PC's name and above its workspaces: one button holding three
meters in a row, each a label, the percent in `tabular-nums` and a 3px bar on a `--border-strong`
track (the plan meters' construction): **CPU**, **Memory** (without the file cache) and
@@ -3362,7 +3365,7 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
herdr's focus), **Alerts**, **Voice input**, **Agents**,
**Shortcuts** (the platform-resolved global bindings), **Phone & devices** (Connect a phone,
Keep screen on, Install; then Portal, where the server offers it; then paired devices; then Access),
- **Remote PCs** (Add PC, Update PC bridges automatically; then **Show in sidebar**), **About** (Updates, herdr, the repository links). A button that points at
+ **Remote PCs** (Add PC, Update PC bridges automatically, PC load in the sidebar; then **Show in sidebar**), **About** (Updates, herdr, the repository links). A button that points at
Updates opens the dialog on About.
- Every page is built from the same parts (`components/SettingsControls.tsx`). A group is an
optional caption (`--fs-sm`, `--fw-semibold`, `--text-dim`, sentence case) over one card:
diff --git a/changelog.d/pc-system-load.md b/changelog.d/pc-system-load.md
index c42283941e..fd77c0274a 100644
--- a/changelog.d/pc-system-load.md
+++ b/changelog.d/pc-system-load.md
@@ -2,4 +2,6 @@
- Each PC in the sidebar shows its CPU, memory and fullest disk as three small meters under its
name. Tap them for memory used, load average, uptime and every disk. A figure at 85% or more
turns red. Nothing is read for a PC that is folded, offline or in a background tab, and a PC
- whose bridge is from before this release shows nothing until its bridge is updated.
+ whose bridge is from before this release shows nothing until its bridge is updated. It is on by
+ default once two or more PCs are connected; Settings → Remote PCs → PC load in the sidebar turns
+ it on or off on this device.
diff --git a/docs/guide.md b/docs/guide.md
index d35b8fe8cf..d1e94b5d86 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -334,7 +334,7 @@ across an Ovis restart, which forgets where it saw each `letta` before. Check th
| **Search the conversation** | **Search conversation**, from the pane's menu (right-click on a desktop, the tab's ⋯ on a phone) or the command palette, finds a word in what you wrote and in the agent's answers, earlier pages included; tool output, thinking and progress notes are left out. Matches list newest first as snippets; picking one opens the chat at that message, loading the earlier messages up to it. Nothing in the chat is highlighted, and the phone's screen gains no button. A remote PC offers it once its bridge is updated to one that searches. |
| **Find in terminal** | **More → Find in terminal** or Mod+Shift+F searches literal text in herdr's history, with a match count, previous/next navigation and visible match highlights. Search jumps and scrolling move the same viewport in every attached client, including the TUI. Older bridges retain search navigation without automatic highlights. |
| **Jump across PCs** | The command palette (Mod+Shift+K) searches all connected PCs at once by PC, workspace, branch, tab, pane title or agent kind. PC names distinguish results, and Recent remembers each pane's PC. Picking a result opens that PC and pane directly. Disconnected PCs are omitted; app and plugin actions remain on the currently selected PC. It also finds Settings pages and actions by related words (try `theme` or `font`), and browses folders: type a path such as `~/`, or pick **Browse folders**, and the folder you reach opens in Files. |
-| **See each PC's load** | Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open and the page is visible. A remote PC shows nothing until its bridge is updated to this version. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS leaves memory out. |
+| **See each PC's load** | On by default once two or more PCs are connected; turn it on or off for this device in **Settings → Remote PCs → PC load in the sidebar**. Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open and the page is visible. A remote PC shows nothing until its bridge is updated to this version. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS leaves memory out. |
| **Answer prompts** | Approval, question and plan menus become cards. Tap an option, or type its number in the composer. The server checks that the menu is still current before answering. |
| **Compose** | `/` commands and `@` file mentions, any file or image up to 32 MB attached by path, and a draft per pane. Send during work schedules a next-turn message; use its ↑ Send now action to send it now. A strip under the input card names the pane's working folder (the whole path on hover) and, for a workspace of a repository with a worktree open, its branch at the other end; a phone leaves it out. Pick or type a terminal-only command and send normally: after acceptance, the pane's terminal opens over the chat, in this browser only, with **Back to Chat** and **Keep Terminal**; the chat stays where you were reading. For Claude Code's panels (`/config`, `/permissions`, `/theme` and the like) the chat may come back by itself when you close the panel, once Claude records the close; for Codex, omp, pi, gjc, OmO, Hermes (classic CLI), Devin, agy and Grok it comes back after their recorded pickers and panels, once the agent's empty input row is back on the screen; otherwise use **Back to Chat**, the header's Chat, or Back on a phone. If you go back to the same pane's chat while the command's screen is still open, the composer says so (Claude Code and every agent above, not OpenCode) and holds Send, quick replies and a queued message's Send now until the screen closes (**Send anyway** sends the message once); **Show terminal** opens it again. Messages the PC sends by itself at the end of a turn are not held. Automatic queued delivery never changes your view. Chat keeps one quiet **Ran in terminal** row with **Open Terminal**, which shows the terminal over the chat again; ordinary commands keep their outcome chips, and Hermes keeps its informational result cards. `/resume` opens **Earlier sessions…**. [Agent commands in Chat](agent-commands.md) explains where each agent's commands come from and what counts as a result. |
| **Follow every agent** | Live RUN / INPUT / DONE / READY status for all panes, and alerts when an agent needs input, finishes or its terminal ends. |
diff --git a/src/App.tsx b/src/App.tsx
index 0b26764227..96bd58b65e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -51,7 +51,7 @@ import { lastViewedPane } from "./lib/lastViewedPane.ts";
import { currentSidebarPins, movedSidebarPins, pinnedWorkspaceIds } from "./lib/sidebarPins.ts";
import { liveGroupKeys, movedAgentGroup } from "./lib/namedAgentGroups.ts";
import { ReorderContext } from "./lib/machineContext.tsx";
-import { loadSettings, useSettings } from "./lib/settings.ts";
+import { loadSettings, pcLoadOn, useSettings } from "./lib/settings.ts";
import { useHiddenMachines, useShownMachines } from "./lib/hiddenMachines.ts";
import { formatKeys, shortcutDisplayKeys, useShortcuts } from "./lib/shortcuts.ts";
import { useMediaQuery } from "./lib/useMediaQuery.ts";
@@ -877,7 +877,7 @@ export function App() {
{/* a sidebar that cannot be drawn leaves the panes working */}
}>
- { selectTarget("local", paneId, "terminal"); void load(); }} onSetup={(machine, update = false) => { setUpdateRemote(update); setMachineDialog(machine); }} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} />
+ { selectTarget("local", paneId, "terminal"); void load(); }} onSetup={(machine, update = false) => { setUpdateRemote(update); setMachineDialog(machine); }} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} />
diff --git a/src/components/MachineSidebar.tsx b/src/components/MachineSidebar.tsx
index 88ff7c1da4..40a4e38811 100644
--- a/src/components/MachineSidebar.tsx
+++ b/src/components/MachineSidebar.tsx
@@ -50,7 +50,7 @@ export const STATE_WORD: Readonly> = {
/** `activity`: App's live counters per PC and finishes looked at here; both sidebar lists draw from it.
* `machines` are the PCs this device shows; `hiddenCount` says how many it keeps out (Settings → Remote PCs) */
-interface Props { machines: Machine[]; hiddenCount?: number; selectedMachineId: string; selectedPaneId: string | null; activity: SidebarActivity; actions: AppActions; watching?: boolean; /** whether the Herdr app's window owns a PC's workspaces (lib/machineContext.tsx AppLayoutContext); none without it */ appLayout?: (machineId: string) => LayoutOffer; /** a phone's drawer is out: closing it clears the filter */ drawerOpen?: boolean; /** counts the command palette's Filter workspaces and agents: each new count shows the field and focuses it */ filterRequest?: number; onSelect(machineId: string, paneId: string | null): void; onNew(machineId: string): void; onSetup(machine: Machine, update?: boolean): void; /** a sign-in tab the usage popover opened */ onUsageLogin(paneId: string): void }
+interface Props { machines: Machine[]; hiddenCount?: number; selectedMachineId: string; selectedPaneId: string | null; activity: SidebarActivity; actions: AppActions; watching?: boolean; /** whether the Herdr app's window owns a PC's workspaces (lib/machineContext.tsx AppLayoutContext); none without it */ appLayout?: (machineId: string) => LayoutOffer; /** a phone's drawer is out: closing it clears the filter */ drawerOpen?: boolean; /** counts the command palette's Filter workspaces and agents: each new count shows the field and focuses it */ filterRequest?: number; /** each open, connected PC shows its CPU, memory and disk (lib/settings.ts pcLoadOn) */ pcLoad?: boolean; onSelect(machineId: string, paneId: string | null): void; onNew(machineId: string): void; onSetup(machine: Machine, update?: boolean): void; /** a sign-in tab the usage popover opened */ onUsageLogin(paneId: string): void }
export function MachineSidebar(props: Props) {
const t = useT();
const activity = props.activity;
@@ -355,7 +355,7 @@ function MachineGroup({ machine, headerless = false, ...props }: Props & { machi
{!noticed && machine.error && {machineErrorText(machine.error, t)}}
{nextStep !== null && {nextStep}}
- {online && !collapsed && !noticed && }
+ {props.pcLoad && online && !folded && !noticed && }
{editing &&
{machine.kind === "hc" ?
{t("A machine of herdr-core. Add, rename or remove it with hc machine or in the Herdr app.")}
: machine.herdr_profile_id ?
{t("Managed by herdr. Rename, disable or remove it there.")}
: }
{/* The first removal click only arms the action; the second is destructive. */}
diff --git a/src/components/MachineStats.tsx b/src/components/MachineStats.tsx
index 53b1beff57..be660cae14 100644
--- a/src/components/MachineStats.tsx
+++ b/src/components/MachineStats.tsx
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronRight } from "lucide-react";
import type { Machine } from "../../shared/machines.ts";
import type { SystemDisk, SystemReport } from "../../shared/system.ts";
import { useT, type Translate } from "../lib/i18n.ts";
+import { pcLoadOn, useSettings } from "../lib/settings.ts";
+import { SettingsRow, Toggle } from "./SettingsControls.tsx";
import { useSystemStats } from "../lib/system.ts";
import { formatBytes, formatPercentValue, formatUptime, fullestDisk, SYSTEM_HIGH_PERCENT } from "../lib/systemReport.ts";
import "./MachineStats.css";
@@ -81,3 +83,15 @@ export function MachineStats({ machine }: { machine: Machine }) {
{open &&
}
;
}
+
+/**
+ * Settings → Remote PCs' switch for the line above, on this device only. Until it is flipped it
+ * follows the roster: on while two or more PCs are connected, where telling them apart is the point.
+ */
+export function PcLoadSetting({ machines }: { machines: readonly Machine[] }) {
+ const t = useT();
+ const { settings, update } = useSettings();
+ return
+ update({ pcLoad })} />
+ ;
+}
diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx
index 69e47535db..963b5c55c6 100644
--- a/src/components/SettingsDialog.tsx
+++ b/src/components/SettingsDialog.tsx
@@ -26,6 +26,7 @@ import { FileTextSizeRow } from "./FileTextSizeRow.tsx";
import { FilesPanelRow } from "./FilesPanelRow.tsx";
import { onSettingsHistory, recordSettings, settingsEntry, settingsLevels, type SettingsLevel } from "../lib/settingsHistory.ts";
import { Segmented, SettingsGroup, SettingsRow, SettingsSelect, Stepper, Toggle } from "./SettingsControls.tsx";
+import { PcLoadSetting } from "./MachineStats.tsx";
import { FONT_FAMILY_MAX_CHARS, sanitizeFontFamily } from "../lib/fontFamily.ts";
import { UI_FONT_MAX, UI_FONT_MIN, UI_FONT_PRESET_FAMILY, uiFontPreset, type UiFontPreset } from "../lib/typography.ts";
import { canListInstalledFonts, filterFamilies, isMonospaceFamily, queryInstalledFonts } from "../lib/installedFonts.ts";
@@ -1176,6 +1177,7 @@ function RemotePcsPage({ actions, machines, pcSettings, pcSettingsError, onPcSet
onPcSettings({ auto_update_bridges })} />
)}
+
{/* a crash can leave the saved PCs unreadable: the server set the file aside and started without them.
machines.json held the PCs added here; herdr-profile-state.json only the permission to connect to herdr's own */}
{pcSettings?.set_aside?.some(heldAddedPcs) &&
diff --git a/src/lib/i18n/machines.ja.ts b/src/lib/i18n/machines.ja.ts
index 7185d9f10b..3b82384365 100644
--- a/src/lib/i18n/machines.ja.ts
+++ b/src/lib/i18n/machines.ja.ts
@@ -138,4 +138,6 @@ export const MACHINES_JA: Record = {
"CPU {value}": "CPU {value}",
"Memory {value}": "メモリ {value}",
"Disk {value}": "ディスク {value}",
+ "PC load in the sidebar": "サイドバーに PC の負荷を表示",
+ "CPU, memory and the fullest disk under each open PC, on this device only. On by default when two or more PCs are connected.": "開いている各 PC の下に CPU、メモリ、最も使用率の高いディスクを表示します(このデバイスのみ)。2 台以上の PC が接続されているときは既定でオンです。",
};
diff --git a/src/lib/i18n/machines.ko.ts b/src/lib/i18n/machines.ko.ts
index 955185f445..b63d22bd03 100644
--- a/src/lib/i18n/machines.ko.ts
+++ b/src/lib/i18n/machines.ko.ts
@@ -138,4 +138,6 @@ export const MACHINES_KO: Record = {
"CPU {value}": "CPU {value}",
"Memory {value}": "메모리 {value}",
"Disk {value}": "디스크 {value}",
+ "PC load in the sidebar": "사이드바에 PC 부하 표시",
+ "CPU, memory and the fullest disk under each open PC, on this device only. On by default when two or more PCs are connected.": "열린 각 PC 아래에 CPU, 메모리, 가장 많이 찬 디스크를 표시합니다(이 기기에서만). PC가 2대 이상 연결되어 있으면 기본으로 켜집니다.",
};
diff --git a/src/lib/i18n/machines.zh.ts b/src/lib/i18n/machines.zh.ts
index d8b44171e0..aa4153aff9 100644
--- a/src/lib/i18n/machines.zh.ts
+++ b/src/lib/i18n/machines.zh.ts
@@ -138,4 +138,6 @@ export const MACHINES_ZH: Record = {
"CPU {value}": "CPU {value}",
"Memory {value}": "内存 {value}",
"Disk {value}": "磁盘 {value}",
+ "PC load in the sidebar": "在侧边栏显示 PC 负载",
+ "CPU, memory and the fullest disk under each open PC, on this device only. On by default when two or more PCs are connected.": "在每台展开的 PC 下显示 CPU、内存和占用最高的磁盘(仅限此设备)。连接两台或更多 PC 时默认开启。",
};
diff --git a/src/lib/settings.legacy-fixture.ts b/src/lib/settings.legacy-fixture.ts
index a22ee92867..e0b90af827 100644
--- a/src/lib/settings.legacy-fixture.ts
+++ b/src/lib/settings.legacy-fixture.ts
@@ -9,7 +9,7 @@ import { CHAT_WIDTHS, DICTATION_LANGUAGES, DIFF_STYLES, MARKDOWN_WIDTHS, VOICE_B
QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, USAGE_KEYS_MAX, TERMINAL_FONT_MIN, TERMINAL_FONT_MAX,
TERMINAL_WHEEL_SPEED_MIN, TERMINAL_WHEEL_SPEED_MAX, CHAT_FONT_MIN, CHAT_FONT_MAX,
type Settings as CurrentSettings, type ChatWidth, type DictationLanguage, type VoiceButton } from "./settings.ts";
-type Settings = Omit;
+type Settings = Omit;
type Palette = "amber" | "charcoal" | "catppuccin";
const PALETTES: readonly Palette[] = ["amber", "charcoal", "catppuccin"];
const STORAGE_KEY = "ovis:settings";
diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts
index 0eb6df0159..0ebc213b31 100644
--- a/src/lib/settings.test.ts
+++ b/src/lib/settings.test.ts
@@ -12,7 +12,7 @@ import { DEFAULT_KEY_BAR_ITEMS, migrateKeyBarItems } from "./keyBar.ts";
import { SHARED_SETTING_KEYS } from "../../shared/preferences.ts";
import { NOTICE_VERSION, TELEMETRY_SETTINGS_KEYS, TELEMETRY_THEME_SIDE_KEYS, telemetrySettings } from "../../shared/telemetry.ts";
import { SETTING_SCOPE, telemetrySettingsSnapshot, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, MARKDOWN_WIDTHS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, VOICE_ENGINES, wantsVoiceInput } from "./settings.ts";
-import { applyToDocument, loadSettings, saveSettings } from "./settings.ts";
+import { applyToDocument, loadSettings, pcLoadOn, saveSettings } from "./settings.ts";
import { sanitizeSettings as oldSanitizeSettings, saveSettings as oldSaveSettings } from "./settings.legacy-fixture.ts";
import { themeFor } from "./themeFor.ts";
@@ -447,6 +447,21 @@ it("keeps the composer's New tab button off until this device turns it on, and k
expect(SHARED_SETTING_KEYS as readonly string[]).not.toContain("composerNewTabButton");
});
+it("shows each PC's load while two or more PCs are connected, until this device chooses", () => {
+ const pc = (state: string) => ({ state });
+ expect(sanitizeSettings({}).pcLoad).toBeNull();
+ for (const pcLoad of ["true", 1, null]) expect(sanitizeSettings({ pcLoad }).pcLoad).toBeNull();
+ expect(sanitizeSettings({ pcLoad: false }).pcLoad).toBe(false);
+ // unchosen: a second PC that is not connected does not count
+ expect(pcLoadOn({ pcLoad: null }, [pc("connected")])).toBe(false);
+ expect(pcLoadOn({ pcLoad: null }, [pc("connected"), pc("disconnected")])).toBe(false);
+ expect(pcLoadOn({ pcLoad: null }, [pc("connected"), pc("connected")])).toBe(true);
+ // a choice holds whatever the roster says
+ expect(pcLoadOn({ pcLoad: true }, [pc("connected")])).toBe(true);
+ expect(pcLoadOn({ pcLoad: false }, [pc("connected"), pc("connected")])).toBe(false);
+ expect(SHARED_SETTING_KEYS as readonly string[]).not.toContain("pcLoad");
+});
+
it("keeps the composer's Artifacts chip off until this device turns it on, and keeps that choice its own", () => {
expect(sanitizeSettings({}).composerArtifacts).toBe(false);
expect(sanitizeSettings({ composerArtifacts: true }).composerArtifacts).toBe(true);
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 98c1c202bf..9471d14adf 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -89,6 +89,9 @@ export interface Settings {
/** the Filter field above the sidebar's lists (components/MachineSidebar.tsx); off, the command palette's
* Filter workspaces and agents still opens it for the time it holds words or the focus */
sidebarFilter: boolean;
+ /** CPU, memory and the fullest disk under each open PC in the sidebar (components/MachineStats.tsx); null until
+ * chosen on this device, which shows it while two or more PCs are connected (pcLoadOn) */
+ pcLoad: boolean | null;
/** The Agents list's order. Activity is display-only: herdr's own order never changes. */
agentOrder: AgentOrder;
/** this device lists the agents above the workspaces, in groups by state (lib/agentRoster.ts groupAgentRows); off, the list is as agentOrder says */
@@ -270,13 +273,13 @@ export type DiffStyle = (typeof DIFF_STYLES)[number];
/** Raw custom text, key combinations and account keys are never copied. Requiring every other
* Settings field in the projection makes a new field require an explicit collection decision.
- * keyBarExpanded, agentLocks, lockedAgents, the named agent groups, fileOpen, webLinks, reuseExternalLinkTab, the launch modes, followHerdrFocus, autoTabNames, composerNewTabButton, composerArtifacts, composerPasteSend, composerBtw, terminalMirrorWrap, terminalImages, terminalGhostty, terminalAttachButton, terminalKeyboardOnRequest, terminalFoldKeyBar, sidebarFilter, voiceEngine, the agent groups, the
+ * keyBarExpanded, agentLocks, lockedAgents, the named agent groups, fileOpen, webLinks, reuseExternalLinkTab, the launch modes, followHerdrFocus, autoTabNames, composerNewTabButton, composerArtifacts, composerPasteSend, composerBtw, terminalMirrorWrap, terminalImages, terminalGhostty, terminalAttachButton, terminalKeyboardOnRequest, terminalFoldKeyBar, sidebarFilter, pcLoad, voiceEngine, the agent groups, the
* interface font, the file text size, the chat's history page size and the folds its blocks start in are not collected: the telemetry schema
* (shared/telemetry.ts) does not carry them. */
type TelemetrySourceSettings = Omit;
@@ -380,6 +383,8 @@ export const SETTING_SCOPE = {
composerBtw: "device",
// a desk with a keyboard may do without the field a phone keeps, as with the PCs it shows (lib/hiddenMachines.ts)
sidebarFilter: "device",
+ // a phone's narrow sidebar may do without the line a desk keeps, as with the filter
+ pcLoad: "device",
// a phone may group its agents by state while a desk keeps herdr's order, as with the filter
agentGroups: "device", agentGroupSort: "device",
keepScreenOn: "device", alertsOn: "device", alertInput: "device", alertDone: "device", alertInApp: "device", alertSound: "device",
@@ -406,6 +411,7 @@ export const DEFAULT_SETTINGS: Settings = {
density: "comfortable",
sidebarRows: "one",
sidebarFilter: true,
+ pcLoad: null,
agentOrder: "workspace",
agentGroups: false,
agentGroupSort: "wait",
@@ -614,6 +620,7 @@ export function sanitizeSettings(raw: unknown): Settings {
density: density === "compact" || density === "comfortable" ? density : DEFAULT_SETTINGS.density,
sidebarRows: record["sidebarRows"] === "one" || record["sidebarRows"] === "two" ? record["sidebarRows"] : DEFAULT_SETTINGS.sidebarRows,
sidebarFilter: typeof record["sidebarFilter"] === "boolean" ? record["sidebarFilter"] : DEFAULT_SETTINGS.sidebarFilter,
+ pcLoad: typeof record["pcLoad"] === "boolean" ? record["pcLoad"] : null,
agentOrder: record["agentOrder"] === "workspace" || record["agentOrder"] === "activity" ? record["agentOrder"] : DEFAULT_SETTINGS.agentOrder,
agentGroups: typeof record["agentGroups"] === "boolean" ? record["agentGroups"] : DEFAULT_SETTINGS.agentGroups,
agentGroupSort: AGENT_GROUP_SORTS.includes(record["agentGroupSort"] as AgentGroupSort) ? record["agentGroupSort"] as AgentGroupSort : DEFAULT_SETTINGS.agentGroupSort,
@@ -899,6 +906,11 @@ function coarsePointer(): boolean {
}
/** Whether Enter (Return) sends from the composer: the device's choice, else not on a touch screen. */
+/** Whether the sidebar shows each open PC's load: as chosen on this device, else while two or more PCs are connected. */
+export function pcLoadOn(settings: Pick, machines: readonly { state: string }[]): boolean {
+ return settings.pcLoad ?? machines.filter((machine) => machine.state === "connected").length >= 2;
+}
+
export function enterSendsOn(settings: Pick, touch: boolean): boolean {
return settings.enterSends ?? !touch;
}
From ac4efcccbd9474c4480253acb763b5bf05bd77b4 Mon Sep 17 00:00:00 2001
From: devswha
Date: Fri, 6 Nov 2026 03:55:26 +0900
Subject: [PATCH 3/6] fix(server): no load from a herdr-core machine's server,
one handler on a stuck disk read, bridge token GET only
---
server/api.contract.test.ts | 11 +++++++++--
server/dispatch.ts | 4 ++--
server/index.ts | 9 +++++----
server/system-stats.test.ts | 30 +++++++++++++++++++++++++-----
server/system-stats.ts | 28 ++++++++++++++++++++--------
5 files changed, 61 insertions(+), 21 deletions(-)
diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts
index 54a0a6571a..698cc3b0ff 100644
--- a/server/api.contract.test.ts
+++ b/server/api.contract.test.ts
@@ -660,7 +660,9 @@ describe("system API", () => {
it("answers GET without caching, keeps it behind the token gate, and takes the bridge token too", async () => {
const dir = mkdtempSync(join(tmpdir(), "herdr-system-contract-"));
const open = createServer({ port: 0, stateDir: dir, system: sampler });
- const gated = createServer({ port: 0, stateDir: dir, system: sampler, token: "system-test-token" });
+ const bridge = "b".repeat(64);
+ const gated = createServer({ port: 0, stateDir: dir, system: sampler, token: "system-test-token", bridgeToken: bridge });
+ const machineServer = createServer({ port: 0, stateDir: dir, system: null });
try {
const answered = await fetch(`http://localhost:${open.port}/api/system`);
expect(answered.status).toBe(200);
@@ -669,8 +671,13 @@ describe("system API", () => {
expect((await fetch(`http://localhost:${gated.port}/api/system`)).status).toBe(401);
const withToken = await fetch(`http://localhost:${gated.port}/api/system`, { headers: { authorization: "Bearer system-test-token" } });
expect(withToken.status).toBe(200);
+ // another PC's app reads it through its bridge token, by GET only
+ expect((await fetch(`http://localhost:${gated.port}/api/system`, { headers: { authorization: `Bearer ${bridge}` } })).status).toBe(200);
+ expect((await fetch(`http://localhost:${gated.port}/api/system`, { method: "POST", headers: { authorization: `Bearer ${bridge}` } })).status).toBe(401);
expect((await fetch(`http://localhost:${open.port}/api/system`, { method: "POST", headers: { origin: `http://localhost:${open.port}` } })).status).toBe(405);
- } finally { open.stop(); gated.stop(); rmSync(dir, { recursive: true, force: true }); }
+ // a server without a load of its own to tell answers as one without the route
+ expect((await fetch(`http://localhost:${machineServer.port}/api/system`)).status).toBe(404);
+ } finally { open.stop(); gated.stop(); machineServer.stop(); rmSync(dir, { recursive: true, force: true }); }
});
});
diff --git a/server/dispatch.ts b/server/dispatch.ts
index ad89bc1c99..b2c8550219 100644
--- a/server/dispatch.ts
+++ b/server/dispatch.ts
@@ -96,7 +96,7 @@ export interface RequestServices {
readonly usageLogin: UsageLoginService;
readonly usage: UsageService;
readonly usageAccounts: UsageAccountList;
- readonly systemSampler: SystemSampler;
+ readonly systemSampler: SystemSampler | null;
readonly voice: VoiceService;
readonly browserSnapshot: (snapshot: S) => S;
readonly core: ReturnType;
@@ -145,7 +145,7 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
// custom header (update-api.ts), and whoever holds the token already drives every pane here. Only those two
// requests, each by its own method and with no query: the token opens nothing else of these routes (#2054)
const bridgeUpdatePath = url.search === "" && (pathname === "/api/updates" ? request.method === "GET" : pathname === "/api/updates/install" && request.method === "POST");
- const bridgePath = pathname === "/api/bridge" || pathname === "/api/session" || pathname === SESSION_HISTORY_PATH || pathname === SESSION_RESUME_PATH || pathname === "/api/agents" || pathname === "/api/agent-catalog" || pathname === INSTALL_PATH || pathname === SIGN_IN_PATH || pathname === "/api/agent/rename" || pathname === "/api/agent/commands" || pathname === "/api/integrations" || pathname === "/api/system" || pathname === "/api/usage/statusline" || pathname === "/api/plugins/actions" || pathname === "/api/plugin/action" || pathname.startsWith("/api/pane/") || pathname.startsWith("/api/workspace/") || pathname.startsWith("/api/worktree/") || pathname.startsWith("/api/tab/") || pathname === "/api/layout/ratio" || pathname.startsWith("/api/fs/") || pathname === "/ws" || bridgeUpdatePath;
+ const bridgePath = pathname === "/api/bridge" || pathname === "/api/session" || pathname === SESSION_HISTORY_PATH || pathname === SESSION_RESUME_PATH || pathname === "/api/agents" || pathname === "/api/agent-catalog" || pathname === INSTALL_PATH || pathname === SIGN_IN_PATH || pathname === "/api/agent/rename" || pathname === "/api/agent/commands" || pathname === "/api/integrations" || (pathname === "/api/system" && request.method === "GET") || pathname === "/api/usage/statusline" || pathname === "/api/plugins/actions" || pathname === "/api/plugin/action" || pathname.startsWith("/api/pane/") || pathname.startsWith("/api/workspace/") || pathname.startsWith("/api/worktree/") || pathname.startsWith("/api/tab/") || pathname === "/api/layout/ratio" || pathname.startsWith("/api/fs/") || pathname === "/ws" || bridgeUpdatePath;
const ip = relayPeer === undefined ? ctx.requestIP(request) : null;
const loopback = ip !== null && isLoopbackAddress(ip.address);
const forwarded = relayPeer === undefined && cameThroughProxy(request.headers);
diff --git a/server/index.ts b/server/index.ts
index a259a27508..92c71d4d04 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -25,7 +25,7 @@ import { AGENT_CATALOG, catalogPlatform } from "../shared/agent-catalog.ts";
import { AgentInstaller } from "./agent-catalog-install.ts";
import { type BackgroundAgentDeps } from "./background-agent.ts";
import { herdrSocketPath, ping } from "./herdr/client.ts";
-import { coreConnection, coreSeenMarker, hcBin, NoHerdrCoreError, servingCore, setRuntime, startRuntime } from "./herdr-core/runtime.ts";
+import { coreConnection, coreSeenMarker, hcBin, hcMachine, NoHerdrCoreError, servingCore, setRuntime, startRuntime } from "./herdr-core/runtime.ts";
import { chooseRuntime, runtimeService, type RuntimeChoice, type RuntimeService } from "./herdr-core/runtime-choice.ts";
import { bridgePins } from "./herdr-core/pin-label.ts";
import { herdrCoreFacts } from "./herdr-core/discovery.ts";
@@ -167,8 +167,8 @@ export function createServer(
telemetry?: Telemetry;
/** plan limits of the AI subscriptions signed in here; tests pass one without real sign-ins */
usage?: UsageService;
- /** this PC's load; tests pass one that reads nothing from the machine */
- system?: SystemSampler;
+ /** this PC's load; tests pass one that reads nothing from the machine, or null for a server that tells none */
+ system?: SystemSampler | null;
/** Tests supply an isolated proxy service, never a user's configured instance. */
cliProxy?: CLIProxyService;
/** Tests use a disposable credential home and stand-in CLI binaries. */
@@ -272,7 +272,8 @@ export function createServer(
usageLogin.persistTo(stateDir);
const usage = options.usage ?? new UsageService(undefined, undefined, (refresh) => cliProxy.report(refresh));
const usageAccounts = new UsageAccountList(stateDir, usage);
- const systemSampler = options.system ?? createSystemSampler();
+ // a herdr-core machine's server runs on this PC, so it has no load of that machine to tell (system-stats.ts)
+ const systemSampler = options.system === undefined ? (hcMachine() === null ? createSystemSampler() : null) : options.system;
// a sign-in the browser followed ends in the server's own PTY, not a herdr pane: it says so itself
usageLogin.onLoginEnded((account) => {
usage.invalidate();
diff --git a/server/system-stats.test.ts b/server/system-stats.test.ts
index f8e765a8ab..6562d5b0a5 100644
--- a/server/system-stats.test.ts
+++ b/server/system-stats.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
-import { cpuPercent, cpuTimesOf, createSystemSampler, diskOf, handleSystemRequest, parseMeminfo, STALE_MS, type SystemIo } from "./system-stats.ts";
+import { cpuPercent, cpuTimesOf, createSystemSampler, DISK_STALE_MS, diskOf, handleSystemRequest, parseMeminfo, STALE_MS, type SystemIo } from "./system-stats.ts";
const MEMINFO = `MemTotal: 16384000 kB
MemFree: 1000000 kB
@@ -132,17 +132,31 @@ describe("the sampler", () => {
expect(asked).toBe(1);
});
+ it("stops showing the last disks once a stuck read has outlasted them", async () => {
+ const disk = { path: "/", used: 5, total: 10, percent: 50 };
+ let call = 0;
+ const { io, advance } = fakeIo({ disks: () => { call += 1; return call === 1 ? Promise.resolve([disk]) : new Promise(() => {}); } });
+ const sampler = createSystemSampler(io, { diskCapMs: 20 });
+ expect((await sampler.read()).disks).toEqual([disk]);
+ advance(5000);
+ expect((await sampler.read()).disks).toEqual([disk]);
+ advance(DISK_STALE_MS);
+ expect((await sampler.read()).disks).toEqual([]);
+ expect(call).toBe(2);
+ });
+
it("keeps the last disks while one read is late, and takes the new answer when it arrives", async () => {
const disk = { path: "/", used: 5, total: 10, percent: 50 };
- let release: (list: typeof disk[]) => void = () => {};
+ const late = Promise.withResolvers();
let call = 0;
- const { io, advance } = fakeIo({ disks: () => { call += 1; return call === 1 ? Promise.resolve([disk]) : new Promise((done) => { release = done; }); } });
+ const { io, advance } = fakeIo({ disks: () => { call += 1; return call === 1 ? Promise.resolve([disk]) : late.promise; } });
const sampler = createSystemSampler(io, { diskCapMs: 20 });
expect((await sampler.read()).disks).toEqual([disk]);
advance(5000);
expect((await sampler.read()).disks).toEqual([disk]);
- release([{ ...disk, percent: 60 }]);
- await Bun.sleep(5); // fixed wait: lets the released read settle before the next ask
+ late.resolve([{ ...disk, percent: 60 }]);
+ // the sampler's own handler on that read was attached first, so it has run once this await returns
+ await late.promise;
advance(5000);
expect((await sampler.read()).disks[0]?.percent).toBe(60);
});
@@ -155,4 +169,10 @@ describe("the sampler", () => {
expect((await ok.json() as { cpu_count: number }).cpu_count).toBe(4);
expect((await handleSystemRequest(new Request("http://x/api/system", { method: "POST" }), sampler)).status).toBe(405);
});
+
+ it("answers 404 where it has no load of its own to tell (a herdr-core machine's server on this PC)", async () => {
+ const answer = await handleSystemRequest(new Request("http://x/api/system"), null);
+ expect(answer.status).toBe(404);
+ expect(await answer.json()).toMatchObject({ error: { code: "system_unavailable" } });
+ });
});
diff --git a/server/system-stats.ts b/server/system-stats.ts
index 5911bddf5c..bf1ac21e43 100644
--- a/server/system-stats.ts
+++ b/server/system-stats.ts
@@ -33,6 +33,8 @@ export const FIRST_WINDOW_MS = 250;
export const STALE_MS = 30_000;
/** how long the disks may take before they are left out of a reading */
export const DISK_CAP_MS = 1500;
+/** while a disk read is stuck, the last answer stands in for this long, then the disks are left out */
+export const DISK_STALE_MS = 60_000;
/** a filesystem smaller than this (a boot partition, a tmpfs) is not a disk that fills up */
const MIN_DISK_BYTES = 1024 ** 3;
const MAX_DISKS = 4;
@@ -158,17 +160,22 @@ export function createSystemSampler(io: SystemIo = realIo, options: { diskCapMs?
let previous: { at: number; cpu: CpuTimes; cores: number } | null = null;
let last: SystemReport | null = null;
let inflight: Promise | null = null;
- let lastDisks: SystemDisk[] = [];
- let diskRead: Promise | null = null;
+ let lastDisks: { list: SystemDisk[]; at: number } | null = null;
+ let diskRead: Promise | null = null;
/**
* The disks, within diskCapMs. A disk read that has not come back is not started again: each
- * one holds a thread of the process's small pool, and a stuck mount would use them all up. The
- * last answer stands in until it does.
+ * one holds a thread of the process's small pool, and a stuck mount would use them all up. Nor
+ * is it waited on again, so a read that never ends holds one handler, not one per ask. The last
+ * answer stands in until it comes back, for DISK_STALE_MS at most.
*/
function disks(): Promise {
- diskRead ??= io.disks().then((list) => { lastDisks = list; return list; }).finally(() => { diskRead = null; });
- return within(diskRead, diskCapMs, () => lastDisks);
+ const known = (): SystemDisk[] => lastDisks !== null && io.now() - lastDisks.at <= DISK_STALE_MS ? lastDisks.list : [];
+ if (diskRead !== null) return Promise.resolve(known());
+ const read = io.disks().then((list) => { lastDisks = { list, at: io.now() }; }, () => { /* left out; the last answer stands in */ })
+ .finally(() => { diskRead = null; });
+ diskRead = read;
+ return within(read.then(known), diskCapMs, known);
}
async function take(): Promise {
@@ -208,11 +215,16 @@ export function createSystemSampler(io: SystemIo = realIo, options: { diskCapMs?
};
}
-/** GET /api/system. Access is decided before this point (index.ts), as for every route. */
-export async function handleSystemRequest(request: Request, sampler: SystemSampler): Promise {
+/**
+ * GET /api/system. Access is decided before this point (index.ts), as for every route. No sampler:
+ * this server runs here for another machine (a herdr-core machine's server, machine-bridge.ts), so
+ * this PC's load is not that machine's; it answers 404 as a server without the route does.
+ */
+export async function handleSystemRequest(request: Request, sampler: SystemSampler | null): Promise {
if (request.method !== "GET" && request.method !== "HEAD") {
return jsonResponse({ error: { code: "method_not_allowed", message: "Use GET" } }, 405);
}
+ if (sampler === null) return jsonResponse({ error: { code: "system_unavailable", message: "This machine's load is not read from here" } }, 404);
const response = jsonResponse(await sampler.read());
response.headers.set("cache-control", "no-store");
return response;
From d60b3ae559cc40822ad42597f7e22afc88a819de Mon Sep 17 00:00:00 2001
From: devswha
Date: Fri, 6 Nov 2026 03:55:26 +0900
Subject: [PATCH 4/6] fix(sidebar): ask for PC load only while the sidebar
shows it, drop stale readings, keep pcLoad in its own key, fit a 240px
sidebar
---
DESIGN.md | 7 +++++--
changelog.d/pc-system-load.md | 5 +++--
docs/guide.md | 2 +-
src/App.tsx | 2 +-
src/components/MachineSidebar.tsx | 9 +++++----
src/components/MachineStats.css | 22 +++++++++++++++-------
src/components/MachineStats.tsx | 11 ++++++-----
src/lib/settings.test.ts | 13 +++++++++++++
src/lib/settings.ts | 18 ++++++++++++++++--
src/lib/system.ts | 12 +++++++++---
10 files changed, 74 insertions(+), 27 deletions(-)
diff --git a/DESIGN.md b/DESIGN.md
index 697cf57dfa..041c08ffe9 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1598,8 +1598,11 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
shows nothing, and so does one that has not answered yet. A figure the PC cannot measure
is a dash, never `0%`. The meters are drawn only; the button's label carries the numbers for a
screen reader ("Show system details for Work PC. CPU 4%, Memory 22%, Disk 50%").
-- Nothing is read for a PC that is folded, offline or in a hidden tab. The next ask starts 5 s
- after the last one came back or timed out, so a stuck PC never has more than one open.
+- Nothing is read for a PC that is folded, offline or in a hidden tab, nor while the sidebar, its
+ PCs list or a phone's drawer is closed. The next ask starts 5 s after the last one came back or
+ timed out, so a stuck PC never has more than one open, and a reading no ask has renewed for a
+ minute is dropped rather than shown as now. A herdr-core machine shows none: its server runs on
+ the PC that reaches it, which cannot read that machine's load.
- Reference: no app listed in `src/AGENTS.md` shows a machine's load in its project list that I
know of (they assume one machine); this follows the app's own plan meters and PC group header,
and differs only in measuring a PC instead of a subscription. No code is borrowed.
diff --git a/changelog.d/pc-system-load.md b/changelog.d/pc-system-load.md
index fd77c0274a..63e15a021f 100644
--- a/changelog.d/pc-system-load.md
+++ b/changelog.d/pc-system-load.md
@@ -1,7 +1,8 @@
### Added
- Each PC in the sidebar shows its CPU, memory and fullest disk as three small meters under its
name. Tap them for memory used, load average, uptime and every disk. A figure at 85% or more
- turns red. Nothing is read for a PC that is folded, offline or in a background tab, and a PC
- whose bridge is from before this release shows nothing until its bridge is updated. It is on by
+ turns red. Nothing is read for a PC that is folded, offline or in a background tab, or while the
+ sidebar is closed, and a PC whose bridge is from before this release shows nothing until its
+ bridge is updated. A herdr-core machine shows none. It is on by
default once two or more PCs are connected; Settings → Remote PCs → PC load in the sidebar turns
it on or off on this device.
diff --git a/docs/guide.md b/docs/guide.md
index d1e94b5d86..bed22ef252 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -334,7 +334,7 @@ across an Ovis restart, which forgets where it saw each `letta` before. Check th
| **Search the conversation** | **Search conversation**, from the pane's menu (right-click on a desktop, the tab's ⋯ on a phone) or the command palette, finds a word in what you wrote and in the agent's answers, earlier pages included; tool output, thinking and progress notes are left out. Matches list newest first as snippets; picking one opens the chat at that message, loading the earlier messages up to it. Nothing in the chat is highlighted, and the phone's screen gains no button. A remote PC offers it once its bridge is updated to one that searches. |
| **Find in terminal** | **More → Find in terminal** or Mod+Shift+F searches literal text in herdr's history, with a match count, previous/next navigation and visible match highlights. Search jumps and scrolling move the same viewport in every attached client, including the TUI. Older bridges retain search navigation without automatic highlights. |
| **Jump across PCs** | The command palette (Mod+Shift+K) searches all connected PCs at once by PC, workspace, branch, tab, pane title or agent kind. PC names distinguish results, and Recent remembers each pane's PC. Picking a result opens that PC and pane directly. Disconnected PCs are omitted; app and plugin actions remain on the currently selected PC. It also finds Settings pages and actions by related words (try `theme` or `font`), and browses folders: type a path such as `~/`, or pick **Browse folders**, and the folder you reach opens in Files. |
-| **See each PC's load** | On by default once two or more PCs are connected; turn it on or off for this device in **Settings → Remote PCs → PC load in the sidebar**. Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open and the page is visible. A remote PC shows nothing until its bridge is updated to this version. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS leaves memory out. |
+| **See each PC's load** | On by default once two or more PCs are connected; turn it on or off for this device in **Settings → Remote PCs → PC load in the sidebar**. Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open, the sidebar is shown and the page is visible. A remote PC shows nothing until its bridge is updated to this version, and a herdr-core machine shows none. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS leaves memory out. |
| **Answer prompts** | Approval, question and plan menus become cards. Tap an option, or type its number in the composer. The server checks that the menu is still current before answering. |
| **Compose** | `/` commands and `@` file mentions, any file or image up to 32 MB attached by path, and a draft per pane. Send during work schedules a next-turn message; use its ↑ Send now action to send it now. A strip under the input card names the pane's working folder (the whole path on hover) and, for a workspace of a repository with a worktree open, its branch at the other end; a phone leaves it out. Pick or type a terminal-only command and send normally: after acceptance, the pane's terminal opens over the chat, in this browser only, with **Back to Chat** and **Keep Terminal**; the chat stays where you were reading. For Claude Code's panels (`/config`, `/permissions`, `/theme` and the like) the chat may come back by itself when you close the panel, once Claude records the close; for Codex, omp, pi, gjc, OmO, Hermes (classic CLI), Devin, agy and Grok it comes back after their recorded pickers and panels, once the agent's empty input row is back on the screen; otherwise use **Back to Chat**, the header's Chat, or Back on a phone. If you go back to the same pane's chat while the command's screen is still open, the composer says so (Claude Code and every agent above, not OpenCode) and holds Send, quick replies and a queued message's Send now until the screen closes (**Send anyway** sends the message once); **Show terminal** opens it again. Messages the PC sends by itself at the end of a turn are not held. Automatic queued delivery never changes your view. Chat keeps one quiet **Ran in terminal** row with **Open Terminal**, which shows the terminal over the chat again; ordinary commands keep their outcome chips, and Hermes keeps its informational result cards. `/resume` opens **Earlier sessions…**. [Agent commands in Chat](agent-commands.md) explains where each agent's commands come from and what counts as a result. |
| **Follow every agent** | Live RUN / INPUT / DONE / READY status for all panes, and alerts when an agent needs input, finishes or its terminal ends. |
diff --git a/src/App.tsx b/src/App.tsx
index 96bd58b65e..78ed539b5a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -877,7 +877,7 @@ export function App() {
{/* a sidebar that cannot be drawn leaves the panes working */}
}>
- { selectTarget("local", paneId, "terminal"); void load(); }} onSetup={(machine, update = false) => { setUpdateRemote(update); setMachineDialog(machine); }} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} />
+ { selectTarget("local", paneId, "terminal"); void load(); }} onSetup={(machine, update = false) => { setUpdateRemote(update); setMachineDialog(machine); }} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} />
diff --git a/src/components/MachineSidebar.tsx b/src/components/MachineSidebar.tsx
index 40a4e38811..2522c26af4 100644
--- a/src/components/MachineSidebar.tsx
+++ b/src/components/MachineSidebar.tsx
@@ -50,7 +50,7 @@ export const STATE_WORD: Readonly> = {
/** `activity`: App's live counters per PC and finishes looked at here; both sidebar lists draw from it.
* `machines` are the PCs this device shows; `hiddenCount` says how many it keeps out (Settings → Remote PCs) */
-interface Props { machines: Machine[]; hiddenCount?: number; selectedMachineId: string; selectedPaneId: string | null; activity: SidebarActivity; actions: AppActions; watching?: boolean; /** whether the Herdr app's window owns a PC's workspaces (lib/machineContext.tsx AppLayoutContext); none without it */ appLayout?: (machineId: string) => LayoutOffer; /** a phone's drawer is out: closing it clears the filter */ drawerOpen?: boolean; /** counts the command palette's Filter workspaces and agents: each new count shows the field and focuses it */ filterRequest?: number; /** each open, connected PC shows its CPU, memory and disk (lib/settings.ts pcLoadOn) */ pcLoad?: boolean; onSelect(machineId: string, paneId: string | null): void; onNew(machineId: string): void; onSetup(machine: Machine, update?: boolean): void; /** a sign-in tab the usage popover opened */ onUsageLogin(paneId: string): void }
+interface Props { machines: Machine[]; hiddenCount?: number; selectedMachineId: string; selectedPaneId: string | null; activity: SidebarActivity; actions: AppActions; watching?: boolean; /** whether the Herdr app's window owns a PC's workspaces (lib/machineContext.tsx AppLayoutContext); none without it */ appLayout?: (machineId: string) => LayoutOffer; /** a phone's drawer is out: closing it clears the filter */ drawerOpen?: boolean; /** counts the command palette's Filter workspaces and agents: each new count shows the field and focuses it */ filterRequest?: number; /** each open, connected PC shows its CPU, memory and disk (lib/settings.ts pcLoadOn) */ pcLoad?: boolean; /** the sidebar is on screen (a wide window's that is not hidden, a phone's open drawer): the PC load is asked for only then */ shown?: boolean; onSelect(machineId: string, paneId: string | null): void; onNew(machineId: string): void; onSetup(machine: Machine, update?: boolean): void; /** a sign-in tab the usage popover opened */ onUsageLogin(paneId: string): void }
export function MachineSidebar(props: Props) {
const t = useT();
const activity = props.activity;
@@ -242,7 +242,7 @@ export function MachineSidebar(props: Props) {
{/* folded, the list stays mounted, hidden, so the branches its rosters read stay findable to the filter */}
{/* a PC kept out on this device is said, with the way to show it again */}
{hiddenCount > 0 && }
@@ -277,7 +277,7 @@ const PCS_FOLD_KEY = "ovis:pcs-collapsed";
/** `headerless`: the sidebar's lone Host, drawn without its header. What the header held is elsewhere: the state
* and its notice are lines of their own, the + is the New workspace row under the workspaces, and nothing is
* folded with no caret to open it. A Host has no manage panel; Add PC is in Settings and the palette. */
-function MachineGroup({ machine, headerless = false, ...props }: Props & { machine: Machine; headerless?: boolean }) {
+function MachineGroup({ machine, headerless = false, listShown, ...props }: Props & { machine: Machine; headerless?: boolean; listShown: boolean }) {
const t = useT();
const [collapsed, setCollapsed] = useState(() => { try { return localStorage.getItem(`ovis:pc-collapsed:${machine.id}`) === "1"; } catch { return false; } });
const [editing, setEditing] = useState(false);
@@ -355,7 +355,8 @@ function MachineGroup({ machine, headerless = false, ...props }: Props & { machi
{!noticed && machine.error && {machineErrorText(machine.error, t)}}
{nextStep !== null && {nextStep}}
- {props.pcLoad && online && !folded && !noticed && }
+ {/* a herdr-core machine's server runs on this PC, which cannot read that machine's load (server/system-stats.ts) */}
+ {props.pcLoad && online && !folded && !noticed && machine.kind !== "hc" && }
{editing &&
{machine.kind === "hc" ?
{t("A machine of herdr-core. Add, rename or remove it with hc machine or in the Herdr app.")}
: machine.herdr_profile_id ?
{t("Managed by herdr. Rename, disable or remove it there.")}
: }
{/* The first removal click only arms the action; the second is destructive. */}
diff --git a/src/components/MachineStats.css b/src/components/MachineStats.css
index 11de127d80..c3eeffe36c 100644
--- a/src/components/MachineStats.css
+++ b/src/components/MachineStats.css
@@ -8,8 +8,12 @@
.machine-stats-summary {
display: flex;
- align-items: center;
- gap: var(--space-3);
+ /* the bars share one line even when a narrow sidebar puts a percent under its label, and that line sits
+ in the middle of the row (a wrapping flex line is placed by align-content; the meters never wrap) */
+ flex-wrap: wrap;
+ align-content: center;
+ align-items: flex-end;
+ gap: var(--space-2);
width: 100%;
min-height: var(--touch-target);
margin: 0;
@@ -24,6 +28,7 @@
.machine-stats-summary > svg {
flex: none;
+ align-self: center;
width: 14px;
height: 14px;
}
@@ -32,9 +37,11 @@
.machine-stats-summary { min-height: var(--side-row); }
}
+/* label and percent on one line, the percent under the label where they do not fit side by side */
.stats-meter {
- display: grid;
- grid-template-columns: 1fr auto;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
align-items: baseline;
flex: 1 1 0;
min-width: 0;
@@ -42,12 +49,13 @@
row-gap: 3px;
}
-.stats-meter-label { color: var(--text-dim); }
+/* a label wider than the narrowest sidebar's meter is cut, never drawn over its neighbour */
+.stats-meter-label { min-width: 0; max-width: 100%; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; white-space: nowrap; }
.stats-meter-value { color: var(--text); font-variant-numeric: tabular-nums; text-align: right; }
.stats-meter.is-high .stats-meter-value { color: var(--status-blocked); }
.stats-bar {
- grid-column: 1 / -1;
+ flex: 1 0 100%;
height: 3px;
overflow: hidden;
border-radius: var(--radius-pill);
@@ -91,5 +99,5 @@
.stats-disk-path { overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; white-space: nowrap; }
.stats-disk-size { color: var(--text); font-variant-numeric: tabular-nums; text-align: right; }
-.stats-disk .stats-meter { grid-column: 1 / -1; grid-template-columns: 1fr; }
+.stats-disk .stats-meter { grid-column: 1 / -1; }
.stats-disk .stats-meter-value { display: none; }
diff --git a/src/components/MachineStats.tsx b/src/components/MachineStats.tsx
index be660cae14..65f86aee01 100644
--- a/src/components/MachineStats.tsx
+++ b/src/components/MachineStats.tsx
@@ -9,7 +9,8 @@ import { useSystemStats } from "../lib/system.ts";
import { formatBytes, formatPercentValue, formatUptime, fullestDisk, SYSTEM_HIGH_PERCENT } from "../lib/systemReport.ts";
import "./MachineStats.css";
-const high = (percent: number | null): string => percent !== null && percent >= SYSTEM_HIGH_PERCENT ? " is-high" : "";
+// on the figure as shown: 84.6 reads "85%", and so is red
+const high = (percent: number | null): string => percent !== null && Math.round(percent) >= SYSTEM_HIGH_PERCENT ? " is-high" : "";
/** One figure with its bar. It is only drawn: the button around it carries the numbers for a screen reader. */
function Meter({ label, percent }: { label: string; percent: number | null }) {
@@ -58,14 +59,14 @@ function spoken(t: Translate, report: SystemReport): string {
/**
* The load of a PC under its name: CPU, memory and its fullest disk as three small meters, and
- * on a tap the numbers behind them. It asks only while it is on screen (lib/system.ts), and
- * draws nothing for a PC that cannot say.
+ * on a tap the numbers behind them. It asks only while `active` (the sidebar, the PCs list and this
+ * PC are shown: lib/system.ts), and draws nothing for a PC that cannot say.
*/
-export function MachineStats({ machine }: { machine: Machine }) {
+export function MachineStats({ machine, active }: { machine: Machine; active: boolean }) {
const t = useT();
const id = useId();
const [open, setOpen] = useState(() => { try { return localStorage.getItem(`ovis:pc-stats:${machine.id}`) === "1"; } catch { return false; } });
- const { report, unavailable } = useSystemStats(machine.id, true, `${machine.state}:${machine.source_boot_id ?? ""}:${machine.herdr?.version ?? ""}`);
+ const { report, unavailable } = useSystemStats(machine.id, active, `${machine.state}:${machine.source_boot_id ?? ""}:${machine.herdr?.version ?? ""}`);
if (unavailable || report === null) return null;
const disk = fullestDisk(report.disks);
const toggle = () => {
diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts
index 0ebc213b31..1489e2b585 100644
--- a/src/lib/settings.test.ts
+++ b/src/lib/settings.test.ts
@@ -126,6 +126,19 @@ describe("saved theme migration", () => {
expect(loadSettings().sidebarFilter).toBe(true);
});
+ it("keeps the PC load choice in its own key, out of an old tab's whole-record save, and unchosen until chosen", () => {
+ expect(loadSettings().pcLoad).toBeNull();
+ saveSettings(sanitizeSettings({ pcLoad: false }));
+ expect(JSON.parse(records.get(main) ?? "{}")).not.toHaveProperty("pcLoad");
+ oldSaveSettings({ ...readOld(), terminalFontSize: 18 });
+ expect(loadSettings().pcLoad).toBe(false);
+ expect(loadSettings().terminalFontSize).toBe(18);
+ saveSettings({ ...loadSettings(), pcLoad: null });
+ expect(loadSettings().pcLoad).toBeNull();
+ records.set("ovis:pc-load", "not json");
+ expect(loadSettings().pcLoad).toBeNull();
+ });
+
it("keeps the agent groups off until chosen, in keys of their own, out of an old tab's whole-record save", () => {
expect(loadSettings()).toMatchObject({ agentGroups: false, agentGroupSort: "wait" });
saveSettings(sanitizeSettings({ agentGroups: true, agentGroupSort: "name" }));
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 9471d14adf..8985450311 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -754,6 +754,19 @@ function readSidebarFilter(): boolean {
}
}
+/** Settings → Remote PCs → PC load in the sidebar, kept in a key of its own for the same reason; null until chosen. */
+export const PC_LOAD_STORAGE_KEY = "ovis:pc-load";
+
+function readPcLoad(): boolean | null {
+ try {
+ const raw = window.localStorage.getItem(PC_LOAD_STORAGE_KEY);
+ const value: unknown = raw === null ? null : JSON.parse(raw);
+ return typeof value === "boolean" ? value : null;
+ } catch {
+ return null;
+ }
+}
+
/** Settings → Appearance → Group agents by state and Within a state, kept in keys of their own for the same reason: an old tab's save drops a field it does not know. */
export const AGENT_GROUPS_STORAGE_KEY = "ovis:agent-groups";
export const AGENT_GROUP_SORT_STORAGE_KEY = "ovis:agent-group-sort";
@@ -920,7 +933,7 @@ export function loadSettings(): Settings {
const stored = readStoredAppearance(() => window.localStorage);
const settings = stored.main === null ? DEFAULT_SETTINGS : sanitizeSettings(stored.main.value);
const merged = stored.themes === null ? settings : sanitizeSettings({ ...settings, ...stored.themes });
- return { ...merged, enterSends: readEnterSends(merged.enterSends), uiFontFamily: readUiFontFamily(), terminalMirrorWrap: readMirrorWrap(), sidebarFilter: readSidebarFilter(), voiceEngine: readVoiceEngine(), ...readAgentGroups(), historyPrompts: readHistoryPrompts(), ...readFileFont(), composerArtifacts: readComposerArtifacts(),
+ return { ...merged, enterSends: readEnterSends(merged.enterSends), uiFontFamily: readUiFontFamily(), terminalMirrorWrap: readMirrorWrap(), sidebarFilter: readSidebarFilter(), pcLoad: readPcLoad(), voiceEngine: readVoiceEngine(), ...readAgentGroups(), historyPrompts: readHistoryPrompts(), ...readFileFont(), composerArtifacts: readComposerArtifacts(),
expandThinking: readChatFold(EXPAND_THINKING_STORAGE_KEY, DEFAULT_SETTINGS.expandThinking), foldWork: readChatFold(FOLD_WORK_STORAGE_KEY, DEFAULT_SETTINGS.foldWork),
reuseExternalLinkTab: readChatFold(REUSE_EXTERNAL_LINK_TAB_STORAGE_KEY, DEFAULT_SETTINGS.reuseExternalLinkTab),
filesPanel: readChatFold(FILES_PANEL_STORAGE_KEY, DEFAULT_SETTINGS.filesPanel),
@@ -941,11 +954,12 @@ export function saveSettings(settings: Settings): boolean {
darkTheme: mirrored.darkTheme, lightTheme: mirrored.lightTheme,
mirror: { palette: mirrored.palette, paletteDark: mirrored.paletteDark, paletteLight: mirrored.paletteLight },
}));
- const { uiFontFamily, terminalMirrorWrap, sidebarFilter, voiceEngine, agentGroups, agentGroupSort, historyPrompts, fileFontSize, fileFontBase, composerArtifacts, composerPasteSend, composerBtw, expandThinking, foldWork, reuseExternalLinkTab, filesPanel, terminalImages, terminalGhostty, terminalAttachButton, terminalKeyboardOnRequest, terminalFoldKeyBar, ...main } = mirrored;
+ const { uiFontFamily, terminalMirrorWrap, sidebarFilter, pcLoad, voiceEngine, agentGroups, agentGroupSort, historyPrompts, fileFontSize, fileFontBase, composerArtifacts, composerPasteSend, composerBtw, expandThinking, foldWork, reuseExternalLinkTab, filesPanel, terminalImages, terminalGhostty, terminalAttachButton, terminalKeyboardOnRequest, terminalFoldKeyBar, ...main } = mirrored;
window.localStorage.setItem(VOICE_ENGINE_STORAGE_KEY, JSON.stringify(voiceEngine));
window.localStorage.setItem(UI_FONT_STORAGE_KEY, JSON.stringify(uiFontFamily));
window.localStorage.setItem(MIRROR_WRAP_STORAGE_KEY, JSON.stringify(terminalMirrorWrap));
window.localStorage.setItem(SIDEBAR_FILTER_STORAGE_KEY, JSON.stringify(sidebarFilter));
+ window.localStorage.setItem(PC_LOAD_STORAGE_KEY, JSON.stringify(pcLoad));
window.localStorage.setItem(AGENT_GROUPS_STORAGE_KEY, JSON.stringify(agentGroups));
window.localStorage.setItem(AGENT_GROUP_SORT_STORAGE_KEY, JSON.stringify(agentGroupSort));
window.localStorage.setItem(HISTORY_PROMPTS_STORAGE_KEY, JSON.stringify(historyPrompts));
diff --git a/src/lib/system.ts b/src/lib/system.ts
index 6ee4af78be..1533d9f497 100644
--- a/src/lib/system.ts
+++ b/src/lib/system.ts
@@ -8,6 +8,8 @@ import { usePageVisible } from "./visibility.ts";
export const SYSTEM_POLL_MS = 5000;
/** an ask that has not come back by now is given up, so a stuck PC never has more than one open */
export const SYSTEM_ASK_TIMEOUT_MS = 4000;
+/** a reading this old, with every ask since failing, is no longer shown as the PC's load */
+export const SYSTEM_STALE_MS = 60_000;
/**
* This PC cannot say, and asking again will not change that until its bridge does. A bridge from
* before the route answers 404; one that has a browser token set refuses what its allowlist does
@@ -27,16 +29,18 @@ export interface SystemState {
}
/**
- * The load of one PC, while `enabled` and the page is on screen. The next ask starts
+ * The load of one PC, while `enabled` (its line is on screen) and the page is visible. The next ask starts
* SYSTEM_POLL_MS after the last one came back or timed out, so a stuck PC holds one request open,
* never a queue of them. Nothing is
* asked for a PC that is folded, offline or in a background tab. A PC whose bridge cannot answer
- * is not asked again until `retryKey` changes (its bridge was updated, or it reconnected).
+ * is not asked again until `retryKey` changes (its bridge was updated, or it reconnected). A reading
+ * that no ask has renewed for SYSTEM_STALE_MS is dropped rather than shown as now.
*/
export function useSystemStats(machineId: string, enabled: boolean, retryKey: string): SystemState {
const [state, setState] = useState({ report: null, unavailable: false });
const visible = usePageVisible();
const missing = useRef(null);
+ const answeredAt = useRef(0);
useEffect(() => {
if (!enabled || !visible || missing.current === retryKey) return;
@@ -47,6 +51,7 @@ export function useSystemStats(machineId: string, enabled: boolean, retryKey: st
const report = await fetchSystem(machineId, AbortSignal.any([abort.signal, AbortSignal.timeout(SYSTEM_ASK_TIMEOUT_MS)]));
if (abort.signal.aborted) return;
missing.current = null;
+ answeredAt.current = Date.now();
setState({ report, unavailable: false });
} catch (error) {
if (abort.signal.aborted) return;
@@ -55,7 +60,8 @@ export function useSystemStats(machineId: string, enabled: boolean, retryKey: st
setState({ report: null, unavailable: true });
return;
}
- // anything else (offline, restarting, a timeout): the last reading stays until the next ask
+ // anything else (offline, restarting, a timeout): the last reading stays until the next ask, while it is recent
+ if (Date.now() - answeredAt.current > SYSTEM_STALE_MS) setState((last) => last.report === null ? last : { report: null, unavailable: false });
}
timer = window.setTimeout(() => void load(), SYSTEM_POLL_MS);
};
From 281fbd78b8d9c9a4b9f00677a0912fce879d7b2f Mon Sep 17 00:00:00 2001
From: devswha
Date: Fri, 6 Nov 2026 03:55:26 +0900
Subject: [PATCH 5/6] test: browser regression for the PC load line
---
docs/development.md | 1 +
scripts/ci-browser.sh | 1 +
scripts/pc-load-demo-regression.ts | 197 +++++++++++++++++++++++++++++
3 files changed, 199 insertions(+)
create mode 100644 scripts/pc-load-demo-regression.ts
diff --git a/docs/development.md b/docs/development.md
index e82b0f9099..a482339375 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -320,6 +320,7 @@ bun scripts/sidebar-split-demo-regression.ts # PCs/Agents height split at 390 an
bun scripts/sidebar-split-access-demo-regression.ts # divider review regressions: filter context and focus return, forced-colors focus, short folded phone windows, click-to-focus, live drag percentage, pointer ownership and cancellation, held-snap regrouping and two-line Agents minimums in both densities/orders; synthetic demo only
bun scripts/sidebar-activity-demo-regression.ts # Agents order Activity and pins: blocked pinned, recency, Pin agent / Unpin agent and Pin workspace / Unpin workspace from a right-click and the context-menu key, the ⋯ on a phone, and a finish opened here drawn as ready until the next one
bun scripts/hide-pc-demo-regression.ts # Remote PCs → Show in sidebar at 390 and 1440 on two demo PCs: a hidden PC leaves the sidebar, Agents and palette, the selection moves, the last PC stays, a reload keeps it, its link still opens, the sidebar's note shows it again
+bun scripts/pc-load-demo-regression.ts # Remote PCs → PC load in the sidebar on three demo PCs at 390 and 1280: on by default, red from 85%, none for a herdr-core machine, no ask while the drawer or list is out of view, off in its own key; the meters fit a 240px sidebar; screenshots light and dark in UI_EVIDENCE_DIR
bun scripts/sidebar-filter-demo-regression.ts # the roster's label filter on two demo PCs: fuzzy matches with their ancestors, the selection kept as context, pins and folds untouched, a live and an offline roster, the drawer and a reload clearing it; screenshots at 390 and 1440 in UI_EVIDENCE_DIR
bun scripts/sidebar-branch-line-demo-regression.ts # herdr's branch line: a repository and an unmarked git folder say their branch under the name, worktrees and folders outside git stay one line, desktop and phone, dark and light
bun scripts/agent-lock-demo-regression.ts # Lock agent on a pinned Agents row (#2432): only a pinned row locks, a locked one keeps its pin, draws a lock and its tab offers no Close tab; Unlock brings Unpin back; 390 and 1280, light and dark
diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh
index 948e3a600b..ba863f3f70 100644
--- a/scripts/ci-browser.sh
+++ b/scripts/ci-browser.sh
@@ -189,6 +189,7 @@ run_script scripts/sidebar-machine-layout-demo-regression.ts
run_script scripts/sidebar-machine-removal-demo-regression.ts
run_script scripts/sidebar-selection-contrast-demo-regression.ts
run_script scripts/hide-pc-demo-regression.ts
+run_script scripts/pc-load-demo-regression.ts
run_script scripts/sidebar-branch-line-demo-regression.ts
run_script scripts/sidebar-tokens-demo-regression.ts
run_script scripts/agent-lock-demo-regression.ts
diff --git a/scripts/pc-load-demo-regression.ts b/scripts/pc-load-demo-regression.ts
new file mode 100644
index 0000000000..01d2dd2a49
--- /dev/null
+++ b/scripts/pc-load-demo-regression.ts
@@ -0,0 +1,197 @@
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { chromium, type Browser, type Page } from "playwright-core";
+import { buildDemoApp } from "./demo-build.ts";
+import { chromePath } from "./chrome-path.ts";
+import { openSettingsPage } from "./settings-page.ts";
+
+// The PC load line (components/MachineStats.tsx) on the unmodified app over the demo's fixture transport, at a
+// phone's and a desktop's width. Only this disposable fixture rewrites the roster into three connected PCs: the
+// Host "Laptop", "Work PC" under load and "Lab", a herdr-core machine; no herdr session is opened. With no choice
+// made the line is on, under Laptop and Work PC and never under Lab (its server runs on the PC that reaches it).
+// Work PC's high figures are red. While the sidebar is out of view (a phone's closed drawer, a desktop's hidden
+// list) no PC is asked, however long it stays so; shown again, it asks at once. Settings -> Remote PCs -> PC load
+// in the sidebar turns it off on this device, in a key of its own. Screenshots go to UI_EVIDENCE_DIR when set.
+const app = mkdtempSync(join(tmpdir(), "ovis-pc-load-demo-"));
+const evidence = process.env.UI_EVIDENCE_DIR;
+const patch = `(() => {
+ const fetchDemo = window.fetch.bind(window), EventSourceDemo = window.EventSource;
+ const json = (value) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json", "cache-control": "no-store" } });
+ const PCS = [["local", "local", "Laptop"], ["work", "ssh", "Work PC"], ["lab", "hc", "Lab"]];
+ let last = null;
+ const roster = (original) => PCS.map(([id, kind, name]) => {
+ const machine = structuredClone(original.find((item) => item.id === "local"));
+ machine.id = id; machine.kind = kind; machine.name = name; machine.state = "connected";
+ return machine;
+ });
+ const gib = 1024 ** 3;
+ const loaded = () => ({ sampled_at: Date.now(), platform: "linux", uptime_s: 12 * 86400 + 4 * 3600, cpu_count: 16, cpu_percent: 93,
+ memory: { used: 57 * gib, total: 64 * gib, percent: 89 }, load: [14.2, 12.8, 11.9],
+ disks: [{ path: "/", used: 433 * gib, total: 466 * gib, percent: 93 }, { path: "/home/dev", used: 1.1 * 1024 * gib, total: 1.8 * 1024 * gib, percent: 61 }] });
+ window.__systemAsks = { local: 0, work: 0, lab: 0 };
+ window.EventSource = class extends EventTarget {
+ constructor(url, options) {
+ super(); this.inner = new EventSourceDemo(url, options); this.readyState = 1;
+ this.inner.onmessage = (event) => {
+ const message = JSON.parse(event.data);
+ if (message.type === "machines") { last = message.machines; message.machines = roster(last); }
+ this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(message) }));
+ };
+ this.inner.onerror = (event) => this.onerror?.(event);
+ this.inner.onopen = (event) => this.onopen?.(event);
+ }
+ close() { this.inner.close(); this.readyState = 2; }
+ };
+ window.fetch = async (input, init) => {
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.href);
+ if (url.pathname === "/api/machines") { const value = await (await fetchDemo(input, init)).json(); last = value.machines; return json({ ...value, machines: roster(last) }); }
+ const match = url.pathname.match(/^\\/api\\/machines\\/([^/]+)(\\/.*)$/);
+ const machine = match ? decodeURIComponent(match[1]) : "local";
+ const path = match ? "/api" + match[2] : url.pathname;
+ if (path === "/api/system") {
+ window.__systemAsks[machine] += 1;
+ if (machine === "work") return json(loaded());
+ }
+ if (match && path === "/api/session" && last) return json({ snapshot: roster(last).find((item) => item.id === machine).snapshot });
+ if (match) return fetchDemo(path + url.search, init);
+ return fetchDemo(input, init);
+ };
+})();`;
+
+let origin = "";
+const group = (page: Page, name: string) => page.locator(`section.machine-group[aria-label="PC ${name}"]`);
+// a screenshot waits for the drawer and the sheet to finish moving, so it shows where they come to rest (a
+// pulse that never ends is not a move)
+const still = (page: Page): Promise => page.waitForFunction(() => document.getAnimations().every((animation) => animation.playState !== "running" || animation.effect?.getComputedTiming().iterations === Infinity));
+const asks = (page: Page): Promise<{ local: number; work: number; lab: number }> => page.evaluate(() => (window as unknown as { __systemAsks: { local: number; work: number; lab: number } }).__systemAsks);
+const total = async (page: Page): Promise => { const n = await asks(page); return n.local + n.work + n.lab; };
+
+async function open(browser: Browser, width: number, theme: "dark" | "light" = "dark", sidebarWidth: number | null = null): Promise<{ page: Page; errors: string[]; phone: boolean; close: () => Promise }> {
+ const phone = width <= 480;
+ const context = await browser.newContext({ viewport: { width, height: phone ? 844 : 900 }, locale: "en-US", hasTouch: phone, isMobile: phone });
+ await context.addInitScript(([theme, sidebarWidth]) => {
+ if (localStorage.getItem("ovis:settings") === null) localStorage.setItem("ovis:settings", JSON.stringify({ language: "en", theme }));
+ if (sidebarWidth !== null) localStorage.setItem("ovis:sidebar-width", String(sidebarWidth));
+ }, [theme, sidebarWidth] as const);
+ const page = await context.newPage();
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ // the poll's 5 s waits run on this clock, so "no ask while hidden" is told by moving it, not by waiting
+ await page.clock.install();
+ await page.goto(`${origin}/`);
+ await page.locator(".conn-live").waitFor({ state: "attached" });
+ return { page, errors, phone, close: () => context.close() };
+}
+
+/** The sidebar in view or out of it: a phone's drawer, a desktop's Show/Hide workspace list. */
+async function showSidebar(page: Page, phone: boolean, shown: boolean): Promise {
+ const name = phone ? (shown ? "Open workspace list" : "Close workspace list") : (shown ? "Show workspace list" : "Hide workspace list");
+ const toggle = page.getByRole("button", { name, exact: true });
+ if (await toggle.count() > 0) await toggle.click();
+ if (shown) await group(page, "Laptop").waitFor();
+ await still(page);
+}
+
+try {
+ await buildDemoApp(app);
+ writeFileSync(join(app, "pc-load.js"), patch);
+ const index = join(app, "index.html");
+ writeFileSync(index, readFileSync(index, "utf8").replace(/