Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas ad71147bd9 Closes #252: port all tests to React Testing Library 2026-09-09 02:07:52 +01:00
Miguel Palhas e9c3882cea Closes #242: port daemon link and restart overlay to React 2026-09-09 02:07:49 +01:00
Miguel Palhas bc87ddee06 feat(web): port daemonLink and restartOverlay to React (#242)
DaemonLink.tsx holds the link state machine as a Zustand store (same
shape as catalogStore/connectionStore) plus a thin controller component
wiring the catalog store's `connected` field to it. RestartOverlay.tsx
renders the blocking modal for down/updating, reusing the existing CSS.

Closes #242
2026-09-09 01:59:49 +01:00
Miguel Palhas 554c9ebb31 Closes #240: add useHashRouter hook 2026-09-09 01:51:02 +01:00
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +01:00
9 changed files with 838 additions and 15 deletions
+12 -12
View File
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
Some((session, worktree))
Some(worktree)
}
_ => None,
}
};
let Some((mut session, worktree)) = flipped else {
let Some(worktree) = flipped else {
return false;
};
let stream = inner
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
if settle_worktree {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
// its outcomes are safe to observe. Re-read the session from the record
// so the sidecar write includes any updates (e.g. a haiku rename) that
// landed between the initial clone and now.
let session = {
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let Some(record) = sessions.get_mut(&id) else {
return false;
};
record.settled = true;
record.session.clone()
};
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+37 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}
@@ -0,0 +1,215 @@
import { describe, expect, it, vi } from "vitest";
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.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, recording the
* delay each was scheduled with. */
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
let next = 1;
const timers = new Map<number, () => void>();
const delays: number[] = [];
return {
delays,
setTimer(fn, ms) {
const h = next++;
timers.set(h, fn);
delays.push(ms);
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("daemon link store", () => {
it("starts in the connecting state until the first probe lands", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
expect(store.getState().state).toBe("connecting");
await store.getState().probe();
expect(store.getState().state).toBe("online");
expect(store.getState().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
graceMs: 1000,
});
store.getState().noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(store.getState().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(store.getState().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 store = createDaemonLinkStore({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload,
});
await store.getState().probe();
reply = null;
store.getState().noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(store.getState().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await store.getState().probe();
expect(reload).not.toHaveBeenCalled();
expect(store.getState().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 store = createDaemonLinkStore({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await store.getState().probe();
store.getState().noteDisconnected();
reply = info("bbb", "i2");
await store.getState().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(store.getState().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const store = createDaemonLinkStore({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
store.getState().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);
store.getState().stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
store.subscribe((s) => seen.push(s.state));
await store.getState().probe();
store.getState().noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
});
it("schedules the grace timer and poll at the documented defaults", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
expect(GRACE_MS).toBe(1200);
expect(POLL_MS).toBe(700);
store.getState().noteDisconnected();
expect(sched.delays).toEqual([GRACE_MS]);
sched.run(); // grace fires → schedules the first poll
await Promise.resolve();
await Promise.resolve();
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore } from "./DaemonLink.js";
import { DaemonLink } from "./DaemonLink.js";
afterEach(() => cleanup());
describe("DaemonLink", () => {
it("probes once on mount, regardless of the initial connected value", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
render(<DaemonLink store={store} connected={false} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
});
it("does not treat the initial connected value as a transition", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
// Only the mount probe ran — no extra noteConnected() probe on top of it.
expect(store.getState().state).toBe("online");
});
it("forwards a later connected drop to noteDisconnected", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
});
it("forwards a later connected recovery to noteConnected", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
// The socket dropped: noteDisconnected() moves off "online" so the coming
// recovery isn't a no-op.
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
fetchInfo.mockClear();
rerender(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
expect(store.getState().state).toBe("online");
});
it("stops the store's polling on unmount", () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
});
const stop = vi.spyOn(store.getState(), "stop");
const { unmount } = render(<DaemonLink store={store} connected={false} />);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
});
+234
View File
@@ -0,0 +1,234 @@
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
// renders what this reports. See docs/RESTART.md for the restart contract.
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { getDaemonInfo } from "../../api.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";
/** 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;
/**
* 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(" ");
}
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
export interface DaemonLinkState {
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;
}
export interface DaemonLinkActions {
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
probe(): Promise<boolean>;
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void;
/** 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;
/** Stop polling. */
stop(): void;
}
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
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;
}
/** Builds an isolated daemon-link store — same shape as
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
* and a plain object (`getState`/`subscribe`), so it drives the overlay
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
const scheduler = opts.scheduler ?? browserScheduler;
const reload = opts.reload ?? (() => window.location.reload());
const graceMs = opts.graceMs ?? GRACE_MS;
const pollMs = opts.pollMs ?? POLL_MS;
let identity: DaemonInfo | null = null;
let graceTimer: number | null = null;
let pollTimer: number | null = null;
return create<DaemonLinkStore>((set, get) => {
function clearTimers(): void {
if (graceTimer !== null) {
scheduler.clearTimer(graceTimer);
graceTimer = null;
}
if (pollTimer !== null) {
scheduler.clearTimer(pollTimer);
pollTimer = null;
}
}
async function probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await fetchInfo();
} catch {
set((s) => ({ attempts: s.attempts + 1 }));
return false;
}
const before = identity;
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.
set({ state: "updating", build: info.build, hostname: info.hostname });
reload();
return true;
}
clearTimers();
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
return true;
}
async function poll(): Promise<void> {
const state = get().state;
if (state === "online" || state === "updating") return;
const ok = await probe();
if (ok || pollTimer !== null) return;
pollTimer = scheduler.setTimer(() => {
pollTimer = null;
void poll();
}, pollMs);
}
return {
state: "connecting",
build: undefined,
hostname: undefined,
attempts: 0,
probe,
noteDisconnected() {
const state = get().state;
if (state === "down" || state === "updating") return;
set({ state: "reconnecting", attempts: 0 });
if (graceTimer === null) {
graceTimer = scheduler.setTimer(() => {
graceTimer = null;
if (get().state === "reconnecting") set({ state: "down" });
void poll();
}, graceMs);
}
},
noteConnected() {
const state = get().state;
if (state === "online" || state === "updating") return;
void probe();
},
stop: clearTimers,
};
});
}
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
export interface DaemonLinkProps {
/** The link store this instance drives — `RestartOverlay` (and, later, the
* sidebar's status indicator) read the same one. */
store: DaemonLinkStoreApi;
/** The catalog store's `connected` field (docs/RESTART.md): the events
* socket dying is the earliest signal a restart happened, ahead of any
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
connected: boolean;
}
/**
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
* catalog's socket state. Mount once near the app root; `RestartOverlay`
* reads the store it drives.
*/
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
void store.getState().probe();
return;
}
if (connected) store.getState().noteConnected();
else store.getState().noteDisconnected();
}, [store, connected]);
useEffect(() => () => store.getState().stop(), [store]);
return null;
}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
import { RestartOverlay } from "./RestartOverlay.js";
afterEach(() => cleanup());
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
store.setState({ state });
return store;
}
describe("RestartOverlay", () => {
it("renders nothing while connecting, online, or reconnecting", () => {
for (const state of ["connecting", "online", "reconnecting"] as const) {
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
unmount();
}
});
it("blocks with a restarting message when down", () => {
render(<RestartOverlay store={storeAt("down")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro is restarting");
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
});
it("counts attempts once more than one probe has failed", () => {
const store = storeAt("down");
store.setState({ attempts: 3 });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
});
it("says it is reloading when updating", () => {
render(<RestartOverlay store={storeAt("updating")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro was updated");
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
});
it("names the release from the store's build", () => {
const store = storeAt("down");
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
});
it("clears the moment the store reports the link is back", () => {
const store = storeAt("down");
const { rerender } = render(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).not.toBeNull();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
const store = storeAt("down");
const handler = vi.fn();
window.addEventListener("keydown", handler);
const { rerender } = render(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).not.toHaveBeenCalled();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener("keydown", handler);
});
});
@@ -0,0 +1,62 @@
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
// went away — see docs/RESTART.md.
//
// Rendered while the link is `down` or `updating`, gone the moment it is
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
// what the store reports.
import { useEffect } from "react";
import { useStore } from "zustand";
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
export interface RestartOverlayProps {
/** The same store a sibling `DaemonLink` drives. */
store: DaemonLinkStoreApi;
}
/** 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 swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
export function RestartOverlay({ store }: RestartOverlayProps) {
const state = useStore(store, (s) => s.state);
const build = useStore(store, (s) => s.build);
const attempts = useStore(store, (s) => s.attempts);
const blocking = state === "down" || state === "updating";
// 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.
useEffect(() => {
if (!blocking) return;
window.addEventListener("keydown", swallow, true);
return () => window.removeEventListener("keydown", swallow, true);
}, [blocking]);
if (!blocking) return null;
const updating = state === "updating";
return (
<div className="modal-backdrop restart-backdrop">
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
<div className="restart-spinner" aria-hidden="true" />
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
<p className="restart-detail">
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
</p>
<p className="restart-release">{formatRelease(build)}</p>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
let controls: UseHashRouterResult;
function Probe() {
controls = useHashRouter();
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
}
beforeEach(() => {
location.hash = "";
});
afterEach(cleanup);
describe("useHashRouter", () => {
it("starts on catalog for an empty hash", () => {
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("resolves #/attach/:id to the attach route", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:s1");
});
it("decodes an encoded session id", () => {
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:weird id/x");
});
it("falls back to catalog for an unknown route", () => {
location.hash = "#/nonsense";
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("follows a hashchange fired outside the hook", async () => {
const view = render(<Probe />);
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
// synchronously with the assignment.
location.hash = "#/attach/s9";
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
});
it("navigate updates the hash and the route", async () => {
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
expect(location.hash).toBe("#/attach/s2");
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
});
it("navigate back to catalog sets the root hash", async () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "catalog" }));
expect(location.hash).toBe("#/");
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
});
it("navigating to the already-current route still updates state", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
expect(view.container.textContent).toBe("attach:s1");
});
it("stops listening after unmount", async () => {
const view = render(<Probe />);
view.unmount();
location.hash = "#/attach/s3";
// No assertion beyond "doesn't throw" — a leaked listener would only ever
// surface as noise in a later test's act() warnings.
await new Promise((r) => setTimeout(r, 0));
});
});
+47
View File
@@ -0,0 +1,47 @@
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
// bookmark from the vanilla shell still resolves.
import { useCallback, useEffect, useState } from "react";
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
function parseHash(hash: string): Route {
const raw = hash.replace(/^#/, "");
if (raw === "" || raw === "/") return { kind: "catalog" };
const m = raw.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "catalog" };
}
function routeToHash(route: Route): string {
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
}
export interface UseHashRouterResult {
route: Route;
navigate(route: Route): void;
}
export function useHashRouter(): UseHashRouterResult {
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
useEffect(() => {
const onHashChange = () => setRoute(parseHash(location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: Route) => {
const hash = routeToHash(next);
// Setting an unchanged hash never fires `hashchange`, so the state update
// that would otherwise ride on that event has to happen here instead.
if (location.hash === hash) {
setRoute(next);
} else {
location.hash = hash;
}
}, []);
return { route, navigate };
}