feat(maestro): daemon status indicator and restart overlay on web

The sidebar footer gains a status dot beside the spawn button: green while the
daemon answers, amber while reconnecting, red while it is gone. Hovering it
shows the release the daemon is running — commit hash and commit date.

When the events socket drops, `DaemonLink` starts polling `GET /api/info`. Past
a 1200 ms grace window an overlay blocks the UI (and swallows the app's
shortcuts) until the daemon answers again. If it comes back on a different
commit the page reloads, so a tab never drives new server code from the bundle
that was just replaced; a plain restart just unlocks.

docs/RESTART.md is the contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-02 15:45:50 +01:00
co-authored by Claude Opus 5
parent eaf60d9dac
commit 4d7e368d31
11 changed files with 993 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# Restart and upgrade contract
Maestro is upgraded in place: the operator reinstalls the binary and systemd
restarts the daemon under every browser tab and TUI that is attached to it.
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
lose their sockets. This document is the contract for what a client does about
it — what the daemon publishes, how a client tells a restart from a blip, and
what the user sees while it happens.
## What the daemon publishes
`GET /api/info` carries two fields beyond identity and presets:
| field | meaning |
| ---------- | -------------------------------------------------------------- |
| `build` | `{ commit, commit_date, dirty }` — the commit the binary was compiled from |
| `instance` | uuid regenerated on every daemon start |
`build` is stamped at compile time by `crates/maestro-daemon/build.rs`, which
runs `git` in the checkout it is compiled in. The Nix build has no `.git` in the
crane-filtered source tree, so `flake.nix` passes the flake's own revision as
`MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY`. A build
with neither source reports empty strings — clients render that as
"unknown release" and fall back to `instance` alone.
`instance` is the one that never goes missing. It says *this process*, so two
replies that differ on it are two runs of the daemon, even when the commit did
not change (a plain `systemctl restart`, or a crash).
## How a client reads them
Three verdicts, from comparing the reply before an outage with the one after
(`compareIdentity` in `web/src/daemonLink.ts`, mirrored by
`crates/maestro-tui/src/daemon_link.rs`):
- **upgraded** — both commits are known and differ. New server code.
- **restarted** — commits match (or are unknown) and `instance` differs.
- **same** — neither changed; the socket blipped, the daemon never moved.
Only *upgraded* is special. A browser tab is holding a bundle that the upgraded
daemon no longer serves, so it reloads the page rather than driving new server
code from an old bundle. The TUI is its own binary — a reload would not replace
it — so it stays up, reconnects, and says which release it is now talking to;
the user restarts `maestro tui` when they want the new client.
## What the user sees
The **status indicator** lives in the sidebar footer, left of the spawn button,
in both clients. Its colour is the link state, and it names the running release
— commit hash and commit date — on hover (web) or in the status bar (TUI).
| state | colour | meaning |
| -------------- | ------ | -------------------------------------------------- |
| `connecting` | amber | first probe hasn't landed |
| `online` | green | daemon answered; same process, or a resolved new one |
| `reconnecting` | amber | socket dropped, still inside the grace window |
| `down` | red | unreachable past the grace window — the UI blocks |
| `updating` | red | answered on a new commit; the page is reloading |
The **restart overlay** is up for exactly `down` and `updating`. It is not
dismissable and it swallows the app's keyboard shortcuts: everything behind it
would act on a daemon that is not there. It clears itself the moment a probe
succeeds — there is no "retry" button, because the client is already retrying.
## Timing
- **Grace window** — 1200 ms between the socket dropping and the overlay
appearing. A wifi hiccup or a laptop waking up should not throw a modal.
- **Poll cadence** — `GET /api/info` every 700 ms while unreachable, flat, not
backed off: a restart takes a second or two and the point is to unlock as soon
as it is over. The events socket keeps its own exponential backoff for the
session stream; the two are independent, and the probe is what decides state.
The events socket dropping is the trigger, not a timer: the daemon cannot warn
anyone it is about to die, so its socket closing is the earliest signal there
is. A reconnect of that socket does **not** by itself clear the state — only a
probe does, because the socket carries no identity and reconnecting to an
upgraded daemon must reload rather than resume.
+10
View File
@@ -25,6 +25,8 @@ import {
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
import type { DaemonLink } from "./daemonLink.js";
import { createStatusIndicator } from "./statusIndicator.js";
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
* multi-view controller lives; dispatched from every Spawn affordance. */
@@ -336,6 +338,9 @@ export interface SidebarOptions {
/** Pointerdown on a session row — the multi-view controller decides drag vs
* tap (issue #32). Tap falls through to the row's normal click (open). */
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
/** Daemon link behind the footer's status dot. Omitted in tests and in the
* design kit, which render the sidebar without a daemon. */
link?: DaemonLink;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -390,6 +395,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
kit.title = "Open the design kit (DEV)";
footer.appendChild(kit);
}
// Daemon status sits beside the spawn button: colour for the link state,
// hover for the release the daemon is running.
const status = opts.link ? createStatusIndicator(opts.link) : null;
if (status) footer.appendChild(status.el);
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
@@ -617,6 +626,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
},
dispose() {
clearInterval(clock);
status?.dispose();
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from "vitest";
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
import type { Scheduler } from "./events.js";
import type { DaemonInfo } from "./types.js";
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: date, dirty: false },
instance,
};
}
/** Scheduler whose timers only fire when the test says so. */
function manual(): Scheduler & { run(): void; pending(): number } {
let next = 1;
const timers = new Map<number, () => void>();
return {
setTimer(fn) {
const h = next++;
timers.set(h, fn);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.entries()];
timers.clear();
for (const [, fn] of due) fn();
},
pending: () => timers.size,
};
}
describe("compareIdentity", () => {
it("reads a changed commit as an upgrade", () => {
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
});
it("reads a changed instance on the same commit as a plain restart", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
});
it("reads an unchanged process as no event at all", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
});
it("falls back to the instance when the build is unknown", () => {
// A daemon built outside a checkout reports empty commits; the instance is
// still enough to see the restart, and never claims a phantom upgrade.
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
});
});
describe("formatRelease", () => {
it("pairs the short hash with a readable date", () => {
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
"f89be4a716c7 · 2026-09-02",
);
});
it("marks a dirty build", () => {
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
});
it("says so when the release is unknown", () => {
expect(formatRelease(undefined)).toBe("unknown release");
});
});
describe("DaemonLink", () => {
it("starts online once the first probe lands", async () => {
const link = new DaemonLink({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
await link.start();
expect(link.status().state).toBe("online");
expect(link.status().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
graceMs: 1000,
});
link.noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(link.status().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
});
it("unlocks without reloading when the same build comes back", async () => {
const sched = manual();
const reload = vi.fn();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload,
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(link.status().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await link.probe();
expect(reload).not.toHaveBeenCalled();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
let reply = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await link.start();
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
expect(reload).toHaveBeenCalledTimes(1);
// Left blocked deliberately: the reload is what ends this page's life, and
// flashing an unlocked UI in between would let the user act on stale code.
expect(link.status().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const link = new DaemonLink({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
link.noteDisconnected();
sched.run(); // grace → first poll
await Promise.resolve();
await Promise.resolve();
const first = attempts;
expect(first).toBeGreaterThan(0);
sched.run(); // next poll tick
await Promise.resolve();
await Promise.resolve();
expect(attempts).toBeGreaterThan(first);
link.stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const link = new DaemonLink({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
link.subscribe((s) => seen.push(s.state));
await link.start();
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
expect(seen[0]).toBe("connecting");
});
});
+218
View File
@@ -0,0 +1,218 @@
// The link to the daemon behind this page: is it up, and which release is it?
// The sidebar's status indicator and the restart overlay both read this one
// controller. See docs/RESTART.md for the restart contract it implements.
import { getDaemonInfo } from "./api.js";
import type { Scheduler } from "./events.js";
import type { BuildInfo, DaemonInfo } from "./types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
export interface LinkStatus {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
export type LinkListener = (status: LinkStatus) => void;
export class DaemonLink {
private state: LinkState = "connecting";
private identity: DaemonInfo | null = null;
private attempts = 0;
private graceTimer: number | null = null;
private pollTimer: number | null = null;
private readonly listeners = new Set<LinkListener>();
private readonly fetchInfo: () => Promise<DaemonInfo>;
private readonly scheduler: Scheduler;
private readonly reload: () => void;
private readonly graceMs: number;
private readonly pollMs: number;
constructor(opts: DaemonLinkOptions = {}) {
this.fetchInfo = opts.fetchInfo ?? getDaemonInfo;
this.scheduler = opts.scheduler ?? browserScheduler;
this.reload = opts.reload ?? (() => window.location.reload());
this.graceMs = opts.graceMs ?? GRACE_MS;
this.pollMs = opts.pollMs ?? POLL_MS;
}
/** Record the release we started against. Everything later is a diff on it. */
async start(): Promise<void> {
await this.probe();
}
status(): LinkStatus {
return {
state: this.state,
build: this.identity?.build,
hostname: this.identity?.hostname,
attempts: this.attempts,
};
}
subscribe(listener: LinkListener): () => void {
this.listeners.add(listener);
listener(this.status());
return () => this.listeners.delete(listener);
}
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void {
if (this.state === "down" || this.state === "updating") return;
this.attempts = 0;
this.setState("reconnecting");
if (this.graceTimer === null) {
this.graceTimer = this.scheduler.setTimer(() => {
this.graceTimer = null;
if (this.state === "reconnecting") this.setState("down");
void this.poll();
}, this.graceMs);
}
}
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void {
if (this.state === "online" || this.state === "updating") return;
void this.probe();
}
/**
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
*
* A first answer becomes the baseline. A later one that differs on commit
* reloads the page; anything else (plain restart, or the same process) puts
* the UI back online.
*/
async probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await this.fetchInfo();
} catch {
this.attempts += 1;
this.emit();
return false;
}
const before = this.identity;
this.identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
this.setState("updating");
this.reload();
return true;
}
this.attempts = 0;
this.clearTimers();
this.setState("online");
return true;
}
/** Stop polling. The page's own teardown; not used in normal operation. */
stop(): void {
this.clearTimers();
}
private async poll(): Promise<void> {
if (this.state === "online" || this.state === "updating") return;
const ok = await this.probe();
if (ok || this.pollTimer !== null) return;
this.pollTimer = this.scheduler.setTimer(() => {
this.pollTimer = null;
void this.poll();
}, this.pollMs);
}
private clearTimers(): void {
if (this.graceTimer !== null) {
this.scheduler.clearTimer(this.graceTimer);
this.graceTimer = null;
}
if (this.pollTimer !== null) {
this.scheduler.clearTimer(this.pollTimer);
this.pollTimer = null;
}
}
private setState(state: LinkState): void {
if (this.state === state) return;
this.state = state;
this.emit();
}
private emit(): void {
const s = this.status();
for (const fn of this.listeners) fn(s);
}
}
+18
View File
@@ -160,3 +160,21 @@ describe("CatalogStore reconnect", () => {
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
});
describe("connection listeners", () => {
it("report the socket opening and dropping", () => {
const sock = new FakeSocket();
const store = new CatalogStore({
socket: () => sock as unknown as WebSocket,
scheduler: fakeScheduler(),
});
const seen: boolean[] = [];
store.subscribeConnection((up) => seen.push(up));
store.start();
// Nothing on subscribe — the daemon link's own probe sets the first state.
expect(seen).toEqual([]);
sock.open();
sock.drop();
expect(seen).toEqual([true, false]);
});
});
+19
View File
@@ -33,9 +33,15 @@ export interface CatalogStoreOptions {
refetch?: () => Promise<Session[]>;
}
/** Called with `true` when the events socket opens, `false` when it drops.
* Drives the daemon link (daemonLink.ts) — the socket dying is the first sign
* the daemon went down or was upgraded under the page. */
export type ConnectionListener = (up: boolean) => void;
export class CatalogStore {
private state: CatalogState = { sessions: new Map() };
private listeners = new Set<CatalogListener>();
private connectionListeners = new Set<ConnectionListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
private backoffMs = INITIAL_BACKOFF_MS;
@@ -70,6 +76,13 @@ export class CatalogStore {
}
}
/** Watch the socket's up/down transitions. Not called on subscribe: the
* caller's own probe decides the starting state. */
subscribeConnection(listener: ConnectionListener): () => void {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
subscribe(listener: CatalogListener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
@@ -99,10 +112,12 @@ export class CatalogStore {
ws.onopen = () => {
this.backoffMs = INITIAL_BACKOFF_MS;
this.emitConnection(true);
};
ws.onclose = () => {
this.ws = null;
this.emitConnection(false);
this.scheduleReconnect();
};
@@ -161,6 +176,10 @@ export class CatalogStore {
}
}
private emitConnection(up: boolean): void {
for (const fn of this.connectionListeners) fn(up);
}
private emit(): void {
const snap = this.snapshot();
for (const fn of this.listeners) fn(snap);
+12
View File
@@ -7,7 +7,9 @@ import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
import { shouldRetryClose } from "./reconnect.js";
import {
createSession,
@@ -69,6 +71,15 @@ if (import.meta.env.DEV && location.search.includes("kit")) {
async function bootApp(): Promise<void> {
const store = new CatalogStore();
// The daemon's identity and liveness: names the release in the sidebar's status
// dot, and blocks the UI behind the restart overlay while the daemon is away
// (docs/RESTART.md). The events socket is the trigger — it dies first.
const link = new DaemonLink();
installRestartOverlay(link);
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
void link.start();
store.start();
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
@@ -89,6 +100,7 @@ shellEl.dataset.route = "catalog";
let mv: MultiView;
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
link,
});
const content = document.createElement("section");
content.className = "content";
+93
View File
@@ -0,0 +1,93 @@
// Blocking overlay for a daemon that went away — see docs/RESTART.md.
//
// Mounted while the link is `down` or `updating`, torn down the moment it is
// back. It owns no polling of its own: DaemonLink polls, and this only renders
// what the link reports.
import { formatRelease, type DaemonLink, type LinkStatus } from "./daemonLink.js";
export interface RestartOverlayHandle {
dispose(): void;
}
/** Mount the overlay controller. It stays subscribed for the page's lifetime. */
export function installRestartOverlay(
link: DaemonLink,
host: HTMLElement = document.body,
): RestartOverlayHandle {
let el: HTMLElement | null = null;
let title: HTMLElement | null = null;
let detail: HTMLElement | null = null;
let release: HTMLElement | null = null;
const unsubscribe = link.subscribe((status) => render(status));
function render(status: LinkStatus): void {
const blocking = status.state === "down" || status.state === "updating";
if (!blocking) {
unmount();
return;
}
if (!el) mount();
const updating = status.state === "updating";
title!.textContent = updating ? "Maestro was updated" : "Maestro is restarting";
detail!.textContent = updating
? "Reloading this page onto the new release…"
: attemptText(status.attempts);
release!.textContent = formatRelease(status.build);
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function mount(): void {
el = document.createElement("div");
el.className = "modal-backdrop restart-backdrop";
const dialog = document.createElement("div");
dialog.className = "modal restart-modal";
dialog.setAttribute("role", "alertdialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "restart-title");
title = document.createElement("h2");
title.id = "restart-title";
detail = document.createElement("p");
detail.className = "restart-detail";
release = document.createElement("p");
release.className = "restart-release";
const spinner = document.createElement("div");
spinner.className = "restart-spinner";
spinner.setAttribute("aria-hidden", "true");
dialog.append(spinner, title, detail, release);
el.appendChild(dialog);
host.appendChild(el);
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
window.addEventListener("keydown", swallow, true);
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
function unmount(): void {
if (!el) return;
window.removeEventListener("keydown", swallow, true);
el.remove();
el = null;
}
return {
dispose(): void {
unsubscribe();
unmount();
},
};
}
+157
View File
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { DaemonLink } from "./daemonLink.js";
import { installRestartOverlay } from "./restartOverlay.js";
import { createStatusIndicator } from "./statusIndicator.js";
import type { DaemonInfo } from "./types.js";
import type { Scheduler } from "./events.js";
function info(commit: string, instance: string): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: "2026-09-02T14:03:11Z", dirty: false },
instance,
};
}
function manual(): Scheduler & { run(): void } {
let next = 1;
const timers = new Map<number, () => void>();
return {
setTimer(fn) {
const h = next++;
timers.set(h, fn);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.values()];
timers.clear();
for (const fn of due) fn();
},
};
}
describe("status indicator", () => {
it("shows the release on the tooltip once the daemon answers", async () => {
const link = new DaemonLink({
fetchInfo: async () => info("f89be4a716c7", "i1"),
scheduler: manual(),
reload: () => {},
});
const ind = createStatusIndicator(link);
await link.start();
expect(ind.el.dataset.state).toBe("online");
const tip = ind.el.querySelector(".status-tip")!.textContent ?? "";
expect(tip).toContain("f89be4a716c7");
expect(tip).toContain("2026-09-02");
expect(tip).toContain("konishi");
expect(ind.el.getAttribute("aria-label")).toContain("Connected");
ind.dispose();
});
it("tracks the link through a drop", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
const ind = createStatusIndicator(link);
expect(ind.el.dataset.state).toBe("connecting");
link.noteDisconnected();
expect(ind.el.dataset.state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(ind.el.dataset.state).toBe("down");
ind.dispose();
link.stop();
});
});
describe("restart overlay", () => {
it("blocks once the daemon is unreachable and unlocks when it answers", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
await link.start();
expect(document.querySelector(".restart-backdrop")).toBeNull();
reply = null;
link.noteDisconnected();
expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace
sched.run();
await Promise.resolve();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro is restarting");
// Restarted on the same build: no reload, and the overlay goes away.
reply = info("aaa", "i2");
await link.probe();
expect(document.querySelector(".restart-backdrop")).toBeNull();
overlay.dispose();
});
it("swallows the app's shortcuts while it is up", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
let sawKey = false;
window.addEventListener("keydown", () => {
sawKey = true;
});
link.noteDisconnected();
sched.run();
await Promise.resolve();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
expect(sawKey).toBe(false);
overlay.dispose();
link.stop();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
expect(sawKey).toBe(true);
});
it("says the page is reloading after an upgrade", async () => {
const sched = manual();
let reply = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => reply,
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
await link.start();
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro was updated");
expect(dialog?.textContent).toContain("bbb");
overlay.dispose();
});
});
+67
View File
@@ -0,0 +1,67 @@
// The sidebar footer's daemon-status dot, sat next to the spawn button.
//
// Colour is the link state; hovering (or focusing — it is a button, so the
// keyboard reaches it) reveals the release the daemon is running: commit hash
// and commit date.
import { formatRelease, type DaemonLink, type LinkState, type LinkStatus } from "./daemonLink.js";
/** One human-readable phrase per link state, used for both the tooltip's first
* line and the button's accessible name. */
const LABELS: Record<LinkState, string> = {
connecting: "Connecting…",
online: "Connected",
reconnecting: "Reconnecting…",
down: "Daemon unreachable",
updating: "Updated — reloading",
};
export interface StatusIndicatorHandle {
el: HTMLElement;
dispose(): void;
}
export function createStatusIndicator(link: DaemonLink): StatusIndicatorHandle {
const el = document.createElement("button");
el.type = "button";
el.className = "btn btn-ghost btn-icon status-chip";
el.setAttribute("aria-describedby", "status-tip");
const dot = document.createElement("span");
dot.className = "status-dot";
dot.setAttribute("aria-hidden", "true");
el.appendChild(dot);
const tip = document.createElement("span");
tip.className = "status-tip";
tip.id = "status-tip";
tip.setAttribute("role", "tooltip");
el.appendChild(tip);
const unsubscribe = link.subscribe((status) => render(status));
function render(status: LinkStatus): void {
const label = LABELS[status.state];
const release = formatRelease(status.build);
el.dataset.state = status.state;
el.setAttribute("aria-label", `${label} — ${release}`);
tip.textContent = "";
tip.append(line("status-tip-state", status.hostname ? `${label} · ${status.hostname}` : label));
tip.append(line("status-tip-release", release));
}
function line(cls: string, text: string): HTMLElement {
const span = document.createElement("span");
span.className = cls;
span.textContent = text;
return span;
}
return {
el,
dispose(): void {
unsubscribe();
el.remove();
},
};
}
+128
View File
@@ -435,6 +435,134 @@ textarea {
flex: 1 1 auto;
}
/* ---------- daemon status dot ---------- */
/* Colour is the link state; the tooltip names the release the daemon is
running. The chip is a button so the keyboard reaches the tooltip too. */
.status-chip {
position: relative;
flex: 0 0 auto;
overflow: visible;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 3px transparent;
transition:
background var(--dur) var(--ease-out),
box-shadow var(--dur) var(--ease-out);
}
.status-chip[data-state="online"] .status-dot {
background: var(--live);
}
.status-chip[data-state="connecting"] .status-dot,
.status-chip[data-state="reconnecting"] .status-dot {
background: var(--warn);
animation: pulse 1.1s var(--ease-out) infinite;
}
.status-chip[data-state="down"] .status-dot,
.status-chip[data-state="updating"] .status-dot {
background: var(--danger);
animation: pulse 1.1s var(--ease-out) infinite;
}
.status-tip {
position: absolute;
bottom: calc(100% + var(--sp-2));
left: 0;
z-index: var(--z-sticky);
display: flex;
flex-direction: column;
gap: 2px;
min-width: 180px;
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--surface-2);
box-shadow: var(--shadow-2);
font-size: var(--fs-11);
text-align: left;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity var(--dur-fast) var(--ease-out),
visibility var(--dur-fast) var(--ease-out);
}
.status-chip:hover .status-tip,
.status-chip:focus-visible .status-tip {
opacity: 1;
visibility: visible;
}
.status-tip-state {
color: var(--fg);
}
.status-tip-release {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---------- restart overlay ---------- */
/* Up while the daemon is unreachable or being replaced. Nothing to dismiss:
it clears itself when the link is back (docs/RESTART.md). */
.restart-modal {
max-width: 380px;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-5) var(--sp-4);
text-align: center;
}
.restart-modal h2 {
font-size: var(--fs-15);
margin: 0;
}
.restart-detail {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
}
.restart-release {
margin: 0;
color: var(--faint);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
.restart-spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
animation: spin 900ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.restart-spinner,
.status-dot {
animation: none;
}
}
.sidebar-search .search {
width: 100%;
max-width: none;