diff --git a/DESIGN.md b/DESIGN.md index 27de898183..1385f9eb86 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1591,6 +1591,30 @@ 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`) +- 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 + **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, 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. + ### 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 @@ -3351,7 +3375,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 new file mode 100644 index 0000000000..63e15a021f --- /dev/null +++ b/changelog.d/pc-system-load.md @@ -0,0 +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, 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/development.md b/docs/development.md index 8b0d9d9cda..727b0c03c0 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/docs/guide.md b/docs/guide.md index e6d7f5719f..368fc5f3ee 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** | 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/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(/