From eaf60d9dac7000dd28e92e84b97c355e928002ff Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 15:40:41 +0100 Subject: [PATCH 1/5] feat(maestro): stamp the daemon with its build commit `GET /api/info` now carries `build` (commit hash, commit date, dirty flag) and `instance`, a fresh uuid per daemon process. Together they let a client name the release it is talking to and, after losing its socket, tell an upgrade from a plain restart from a network blip. The stamp comes from `maestro-daemon/build.rs`, which reads the git checkout it compiles in. The Nix build has no `.git` in the crane-filtered source, so `flake.nix` passes the flake's own revision and last-modified date through `MAESTRO_BUILD_*` instead. Neither available leaves the fields empty. Co-Authored-By: Claude Opus 5 (1M context) --- .../maestro-cli/tests/against_mock_daemon.rs | 1 + crates/maestro-daemon/build.rs | 75 +++++++++++++++++++ crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/local_api.rs | 2 + crates/maestro-daemon/src/release.rs | 31 ++++++++ crates/maestro-daemon/tests/local_api.rs | 29 +++++++ crates/maestro-protocol/src/lib.rs | 43 +++++++++++ crates/maestro-testing/src/lib.rs | 1 + flake.nix | 20 +++++ web/src/types.ts | 18 +++++ 10 files changed, 221 insertions(+) create mode 100644 crates/maestro-daemon/build.rs create mode 100644 crates/maestro-daemon/src/release.rs diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 7b6f9f8..58066f2 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -29,6 +29,7 @@ async fn info_carries_version_and_presets() { label: "Claude".into(), command: "claude".into(), }], + ..Default::default() }); let info = mock.client().daemon_info().await.expect("info"); diff --git a/crates/maestro-daemon/build.rs b/crates/maestro-daemon/build.rs new file mode 100644 index 0000000..bdb1b66 --- /dev/null +++ b/crates/maestro-daemon/build.rs @@ -0,0 +1,75 @@ +//! Stamps the daemon binary with the commit it was built from, so +//! `GET /api/info` can tell clients which release they are talking to. +//! +//! Two sources, in order: +//! +//! 1. `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY` in +//! the environment. The Nix build has no `.git` in its source tree (crane +//! filters it out), so `flake.nix` passes the flake's own revision this way. +//! 2. The git checkout the crate is being compiled in. +//! +//! Neither available leaves the values empty; clients treat that as "unknown +//! release" rather than an error. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_COMMIT"); + println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DATE"); + println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DIRTY"); + // Rebuild when HEAD moves, so a plain `cargo build` after a commit or a + // branch switch restamps instead of serving the previous hash. + for f in ["HEAD", "ORIG_HEAD"] { + if let Some(p) = git_dir().map(|d| d.join(f)) { + if p.exists() { + println!("cargo:rerun-if-changed={}", p.display()); + } + } + } + + let commit = env_or_git("MAESTRO_BUILD_COMMIT", &["rev-parse", "--short=12", "HEAD"]); + let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]); + let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") { + Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), + Err(_) => !git(&["status", "--porcelain"]).unwrap_or_default().is_empty(), + }; + + println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=MAESTRO_BUILD_DATE={date}"); + println!("cargo:rustc-env=MAESTRO_BUILD_DIRTY={dirty}"); +} + +/// The environment variable if set and non-empty, else what git reports, else +/// an empty string. +fn env_or_git(var: &str, args: &[&str]) -> String { + match std::env::var(var) { + Ok(v) if !v.trim().is_empty() => v.trim().to_string(), + _ => git(args).unwrap_or_default(), + } +} + +/// Run `git` inside the crate's source tree. `None` when git is missing, the +/// tree is not a checkout, or the command fails — every one of which is a +/// legitimate way to build Maestro. +fn git(args: &[&str]) -> Option { + let out = Command::new("git") + .args(args) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Absolute path of the `.git` directory (a worktree's `.git` is a file +/// pointing elsewhere, and `--git-common-dir` resolves both). +fn git_dir() -> Option { + let dir = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?; + if dir.is_empty() { + return None; + } + Some(PathBuf::from(dir)) +} diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 0008a76..a0076ec 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -20,6 +20,7 @@ pub mod panes; pub mod proc; pub mod projects; pub mod pty; +pub mod release; pub mod session; pub mod sidecar; pub mod spawn_history; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 4071925..d570ec2 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -155,6 +155,8 @@ async fn daemon_info(State(s): State) -> Json { version: PROTOCOL_VERSION.into(), home_dir: (*s.home_dir).clone(), presets: (*s.presets).clone(), + build: crate::release::build_info(), + instance: crate::release::instance_id().to_string(), }) } diff --git a/crates/maestro-daemon/src/release.rs b/crates/maestro-daemon/src/release.rs new file mode 100644 index 0000000..41de234 --- /dev/null +++ b/crates/maestro-daemon/src/release.rs @@ -0,0 +1,31 @@ +//! What release this daemon is, and which run of it. +//! +//! Two facts ride `GET /api/info` so clients can render a status indicator and +//! survive an upgrade: +//! +//! * [`build_info`] — the commit the binary was compiled from, stamped by +//! `build.rs`. Shown in the indicator's tooltip; a change across a reconnect +//! means the operator upgraded, so a browser reloads its assets. +//! * [`instance_id`] — a fresh id per daemon process. A change with the same +//! commit means a plain restart, which clients ride out silently. + +use std::sync::OnceLock; + +use maestro_protocol::BuildInfo; + +/// The commit this binary was built from. Every field is empty when the build +/// happened outside a git checkout and no `MAESTRO_BUILD_*` override was given. +pub fn build_info() -> BuildInfo { + BuildInfo { + commit: env!("MAESTRO_BUILD_COMMIT").to_string(), + commit_date: env!("MAESTRO_BUILD_DATE").to_string(), + dirty: env!("MAESTRO_BUILD_DIRTY") == "true", + } +} + +/// Identity of this daemon process, stable for its lifetime and different on +/// every start. Clients diff it across a reconnect to detect a restart. +pub fn instance_id() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| uuid::Uuid::new_v4().to_string()) +} diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index bc44136..d46622c 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -91,6 +91,35 @@ async fn info_returns_daemon_identity() { assert!(resp["version"].is_string()); } +#[tokio::test] +async fn info_reports_build_and_instance() { + // The status indicator needs a release to name and a process to compare + // against after a reconnect. `commit`/`commit_date` are empty when the + // binary was built outside a checkout, so only their presence is asserted; + // `instance` is a fresh uuid per daemon process. + let h = spawn("naps-laptop").await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(resp["build"]["commit"].is_string()); + assert!(resp["build"]["commit_date"].is_string()); + assert!(resp["build"]["dirty"].is_boolean()); + let instance = resp["instance"].as_str().unwrap(); + assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid"); + + // Same process, same id — a client only treats a *changed* id as a restart. + let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(again["instance"].as_str().unwrap(), instance); +} + #[tokio::test] async fn list_sessions_starts_empty_and_reflects_creates() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 2966d42..406b978 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -485,6 +485,40 @@ pub struct SpawnPreset { pub command: String, } +/// Build provenance of the running daemon: the commit it was compiled +/// from and when that commit was authored. +/// +/// Clients show it in the status indicator's tooltip, and compare `commit` +/// across a reconnect to tell an upgrade apart from a plain restart — an +/// upgrade means the served assets changed, so a browser reloads rather than +/// resuming against new code with an old bundle. +/// +/// Stamped at compile time by `maestro-daemon/build.rs`, which reads the git +/// checkout it is built in, or the `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` +/// environment variables when there is no checkout (the Nix build). Every field +/// is empty when neither is available; clients must render that gracefully. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildInfo { + /// Abbreviated commit hash the binary was built from. Empty if unknown. + #[serde(default)] + pub commit: String, + /// Commit date of `commit`, RFC 3339 (`2026-09-02T14:03:11+01:00`). Empty + /// if unknown. + #[serde(default)] + pub commit_date: String, + /// Whether the working tree carried uncommitted changes at build time. + #[serde(default)] + pub dirty: bool, +} + +impl BuildInfo { + /// Whether anything is known about this build. A client with `false` here + /// shows the indicator without a release line rather than an empty one. + pub fn is_known(&self) -> bool { + !self.commit.is_empty() + } +} + /// Payload of `GET /api/info` — daemon identity, protocol version, and the /// spawn presets clients render as one-click tiles. /// @@ -503,6 +537,15 @@ pub struct DaemonInfo { pub home_dir: String, #[serde(default)] pub presets: Vec, + /// Which commit this daemon was built from. + #[serde(default)] + pub build: BuildInfo, + /// Identity of this daemon *process*, regenerated on every start. Two + /// replies carrying different ids mean the daemon restarted in between — + /// which is how a client that lost its socket tells a restart from a + /// network blip, even when the build did not change. + #[serde(default)] + pub instance: String, } /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 14bc430..0a1c535 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -278,6 +278,7 @@ async fn daemon_info(State(s): State) -> Response { version: PROTOCOL_VERSION.into(), home_dir: "/home/mock".into(), presets: Vec::new(), + ..Default::default() }); Json(info).into_response() } diff --git a/flake.nix b/flake.nix index ea300d0..2964c89 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,25 @@ version = (lib.importTOML ./Cargo.toml).workspace.package.version; + # Build provenance for `GET /api/info`. The Rust source tree crane + # hands to the compiler has no `.git`, so `maestro-daemon/build.rs` + # cannot ask git itself — the flake's own revision is passed instead. + # A dirty tree has no `rev`, and `dirtyRev` carries a `-dirty` suffix. + rev = inputs.self.rev or inputs.self.dirtyRev or ""; + # `lastModifiedDate` is `YYYYMMDDhhmmss` in UTC; clients want RFC 3339. + ld = inputs.self.lastModifiedDate or ""; + iso = + if ld == "" then + "" + else + "${builtins.substring 0 4 ld}-${builtins.substring 4 2 ld}-${builtins.substring 6 2 ld}" + + "T${builtins.substring 8 2 ld}:${builtins.substring 10 2 ld}:${builtins.substring 12 2 ld}Z"; + buildStamp = { + MAESTRO_BUILD_COMMIT = builtins.substring 0 12 rev; + MAESTRO_BUILD_DATE = iso; + MAESTRO_BUILD_DIRTY = if inputs.self ? rev then "0" else "1"; + }; + # Cargo source filtering keeps only *.rs, *.toml and Cargo.lock. The # SPA is built separately (below) and handed to the Rust build as a # path, so nothing under web/ needs to be in this source tree. @@ -85,6 +104,7 @@ maestro = craneLib.buildPackage ( commonArgs + // buildStamp // { inherit cargoArtifacts; pname = "maestro"; diff --git a/web/src/types.ts b/web/src/types.ts index 12ffe49..8c20eeb 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -86,6 +86,24 @@ export interface DaemonInfo { home_dir?: string; /** Command presets (claude/opencode/zsh + custom), config-resolved. */ presets: SpawnPreset[]; + /** Which commit the daemon was built from. Absent on older daemons. */ + build?: BuildInfo; + /** Identity of the daemon *process*, regenerated on every start — a changed + * value across a reconnect means the daemon restarted. Absent on older + * daemons, which the restart flow treats as "cannot tell". */ + instance?: string; +} + +/** Build provenance of the running daemon: what the status indicator names in + * its tooltip, and what tells an upgrade apart from a plain restart. Every + * field is empty when the binary was built outside a git checkout. */ +export interface BuildInfo { + /** Abbreviated commit hash. */ + commit: string; + /** Commit date, RFC 3339. */ + commit_date: string; + /** Whether the tree had uncommitted changes at build time. */ + dirty: boolean; } /** One deduplicated recent working dir for the spawn form's recents picker From 4d7e368d310a2c0b55c00d5e4bb73e730870f755 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 15:45:50 +0100 Subject: [PATCH 2/5] feat(maestro): daemon status indicator and restart overlay on web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/RESTART.md | 78 ++++++++++++ web/src/catalog.ts | 10 ++ web/src/daemonLink.test.ts | 193 ++++++++++++++++++++++++++++ web/src/daemonLink.ts | 218 ++++++++++++++++++++++++++++++++ web/src/events.test.ts | 18 +++ web/src/events.ts | 19 +++ web/src/main.ts | 12 ++ web/src/restartOverlay.ts | 93 ++++++++++++++ web/src/statusIndicator.test.ts | 157 +++++++++++++++++++++++ web/src/statusIndicator.ts | 67 ++++++++++ web/src/styles.css | 128 +++++++++++++++++++ 11 files changed, 993 insertions(+) create mode 100644 docs/RESTART.md create mode 100644 web/src/daemonLink.test.ts create mode 100644 web/src/daemonLink.ts create mode 100644 web/src/restartOverlay.ts create mode 100644 web/src/statusIndicator.test.ts create mode 100644 web/src/statusIndicator.ts diff --git a/docs/RESTART.md b/docs/RESTART.md new file mode 100644 index 0000000..4441951 --- /dev/null +++ b/docs/RESTART.md @@ -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. diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 651bab5..62fb106 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -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; diff --git a/web/src/daemonLink.test.ts b/web/src/daemonLink.test.ts new file mode 100644 index 0000000..6387c08 --- /dev/null +++ b/web/src/daemonLink.test.ts @@ -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 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"); + }); +}); diff --git a/web/src/daemonLink.ts b/web/src/daemonLink.ts new file mode 100644 index 0000000..7950b81 --- /dev/null +++ b/web/src/daemonLink.ts @@ -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; + 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(); + private readonly fetchInfo: () => Promise; + 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 { + 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 { + 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 { + 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); + } +} diff --git a/web/src/events.test.ts b/web/src/events.test.ts index ab864ca..724c8f4 100644 --- a/web/src/events.test.ts +++ b/web/src/events.test.ts @@ -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]); + }); +}); diff --git a/web/src/events.ts b/web/src/events.ts index b982bd7..c6b48c8 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -33,9 +33,15 @@ export interface CatalogStoreOptions { refetch?: () => Promise; } +/** 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(); + private connectionListeners = new Set(); 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); diff --git a/web/src/main.ts b/web/src/main.ts index d9a0e60..a7f74da 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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 { 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"; diff --git a/web/src/restartOverlay.ts b/web/src/restartOverlay.ts new file mode 100644 index 0000000..de8141f --- /dev/null +++ b/web/src/restartOverlay.ts @@ -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(); + }, + }; +} diff --git a/web/src/statusIndicator.test.ts b/web/src/statusIndicator.test.ts new file mode 100644 index 0000000..629363f --- /dev/null +++ b/web/src/statusIndicator.test.ts @@ -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 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(); + }); +}); diff --git a/web/src/statusIndicator.ts b/web/src/statusIndicator.ts new file mode 100644 index 0000000..f0a71ba --- /dev/null +++ b/web/src/statusIndicator.ts @@ -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 = { + 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(); + }, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css index 205ea26..97d6a22 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; From 29545d15fe94ce41c2dbc2b5fc7ff4eda9af2889 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 15:52:44 +0100 Subject: [PATCH 3/5] feat(maestro): daemon status indicator and restart overlay in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar gains the TUI's half of the same contract the web client got: a coloured dot plus the release the daemon is running, pinned right so a narrow terminal truncates the key hints rather than the release. `DaemonLink` mirrors web/src/daemonLink.ts — the events loop reports its connects and disconnects, a probe task polls `GET /api/info` while the daemon is unreachable, and past the grace window a modal covers the frame and locks the keyboard (quit excepted) until it answers. A daemon that comes back on a new commit cannot be reloaded into a running TUI binary, so the status bar says `updated: (restart tui)` instead. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 + crates/maestro-daemon/build.rs | 4 +- crates/maestro-tui/src/app.rs | 41 ++- crates/maestro-tui/src/catalog.rs | 14 ++ crates/maestro-tui/src/daemon_link.rs | 343 ++++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 13 +- crates/maestro-tui/src/overlays.rs | 38 ++- crates/maestro-tui/src/render.rs | 179 +++++++++++++- docs/RESTART.md | 22 +- 9 files changed, 640 insertions(+), 19 deletions(-) create mode 100644 crates/maestro-tui/src/daemon_link.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8579633..65a3e10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,11 @@ before changing anything that creates or removes one. sidebars — line layout, activity colours, duration format, state precedence. Read it before changing sidebar rendering. +`docs/RESTART.md` is the contract for what clients do when the daemon restarts +or is upgraded under them — the build stamp on `/api/info`, the status +indicator's states, the blocking overlay, grace and poll timings. Read it +before changing reconnect behaviour on either client. + `nix run .#dev` (or `just dev`) brings up the whole local stack under process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only diff --git a/crates/maestro-daemon/build.rs b/crates/maestro-daemon/build.rs index bdb1b66..6e7ba4f 100644 --- a/crates/maestro-daemon/build.rs +++ b/crates/maestro-daemon/build.rs @@ -32,7 +32,9 @@ fn main() { let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]); let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") { Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), - Err(_) => !git(&["status", "--porcelain"]).unwrap_or_default().is_empty(), + Err(_) => !git(&["status", "--porcelain"]) + .unwrap_or_default() + .is_empty(), }; println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}"); diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22d88d4..a47867e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -26,6 +26,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; use crate::config::TuiConfig; +use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; use crate::emulators::SessionEmulators; use crate::input::{ @@ -58,6 +59,9 @@ pub struct App { help: HelpOverlay, chord: ChordState, daemon: DaemonClient, + /// Liveness + release of the daemon (`docs/RESTART.md`): drives the status + /// bar's indicator, and locks the keyboard behind the restart overlay. + link: DaemonLink, /// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in /// `AppState` so the overlay enum stays comparable — the same reason the /// rename buffer lives here. @@ -127,7 +131,12 @@ pub enum Effect { } impl App { - pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self { + pub fn new( + cfg: TuiConfig, + catalog: CatalogStore, + daemon: DaemonClient, + link: DaemonLink, + ) -> Self { let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel(); let chord_timeout = cfg.chord_timeout; let help = HelpOverlay { @@ -148,6 +157,7 @@ impl App { help, chord: ChordState::new(chord_timeout), daemon, + link, http, rename_input: TextInput::default(), draft_tx, @@ -182,6 +192,10 @@ impl App { _ = self.catalog.changed() => { // Render on next loop iteration. } + _ = self.link.changed() => { + // The status indicator and the restart overlay both read + // the link; redraw on the next loop iteration. + } _ = tokio::time::sleep(Duration::from_millis(33)) => { let effects = self.poll_due_probes_at(std::time::Instant::now()); self.perform(effects).await; @@ -198,6 +212,21 @@ impl App { }; let key = self.clicks.promote(key, std::time::Instant::now()); + // The daemon is gone: everything a key could do goes through it, so + // only the quit binding still resolves until it answers again. + if self.link.is_blocking() { + let known = self.cfg.keymap.known_bindings(self.state.mode); + if let ChordResult::Resolved(canon) = self.chord.feed(&key, &known) { + if matches!( + self.cfg.keymap.resolve(self.state.mode, &canon), + Some(Action::Quit) + ) { + return Ok(true); + } + } + return Ok(false); + } + // Overlay intercept first. let (decision, effects) = self.handle_overlay_input(&key); self.perform(effects).await; @@ -919,6 +948,7 @@ impl App { None => "session".into(), }; + let link_status = self.link.status(); let inputs = RenderInputs { sidebar: &sidebar, home_dir: &self.home_dir, @@ -928,6 +958,7 @@ impl App { draft: self.draft.as_ref(), help: &self.help, rename_input: &self.rename_input, + link: &link_status, }; let mut new_region = RegionMap::default(); @@ -1176,7 +1207,12 @@ mod tests { fn make_app_at(base: &str) -> App { let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); - App::new(cfg, CatalogStore::default(), DaemonClient::new(base)) + App::new( + cfg, + CatalogStore::default(), + DaemonClient::new(base), + DaemonLink::default(), + ) } #[test] @@ -1772,6 +1808,7 @@ mod tests { Duration::from_millis(200), Duration::from_millis(500), ), + DaemonLink::default(), ); app.dispatch(Action::OpenSpawnModal); let req = CreateSessionRequest { diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index e3466f0..2de8298 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -127,11 +127,24 @@ impl CatalogStore { /// apply them. Reconnects with exponential backoff on disconnect. Never /// returns — spawn it on a Tokio task. pub async fn run_events_loop(self, ws_url: String) { + self.run_events_loop_reporting(ws_url, crate::daemon_link::DaemonLink::default()) + .await + } + + /// [`Self::run_events_loop`], reporting every connect and disconnect to + /// `link` — the socket dying is the first sign the daemon went down or was + /// upgraded under the client (`docs/RESTART.md`). + pub async fn run_events_loop_reporting( + self, + ws_url: String, + link: crate::daemon_link::DaemonLink, + ) { let mut backoff = Duration::from_secs(1); loop { match tokio_tungstenite::connect_async(&ws_url).await { Ok((ws, _resp)) => { backoff = Duration::from_secs(1); + link.note_connected(); let (_sink, mut stream) = ws.split(); while let Some(frame) = stream.next().await { match frame { @@ -152,6 +165,7 @@ impl CatalogStore { tracing::debug!(error = ?e, "catalog: connect failed"); } } + link.note_disconnected(std::time::Instant::now()); tracing::debug!(?backoff, "catalog: sleeping before reconnect"); tokio::time::sleep(jitter(backoff)).await; backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30)); diff --git a/crates/maestro-tui/src/daemon_link.rs b/crates/maestro-tui/src/daemon_link.rs new file mode 100644 index 0000000..ab18418 --- /dev/null +++ b/crates/maestro-tui/src/daemon_link.rs @@ -0,0 +1,343 @@ +//! Liveness and identity of the daemon behind this TUI. +//! +//! The status bar renders [`LinkStatus`], and [`LinkState::Down`] puts the +//! restart overlay up and locks the keyboard. `docs/RESTART.md` is the +//! contract; this is the TUI half of what `web/src/daemonLink.ts` does. + +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use maestro_protocol::{BuildInfo, DaemonClient, DaemonInfo}; +use tokio::sync::Notify; + +/// How long a lost connection stays quiet before the overlay blocks the UI. +pub const GRACE: Duration = Duration::from_millis(1200); +/// Cadence of `GET /api/info` while the daemon is unreachable. +pub const POLL: Duration = Duration::from_millis(700); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkState { + /// No probe has landed yet. + Connecting, + /// The daemon answered. + Online, + /// The events socket dropped, still inside the grace window. + Reconnecting, + /// Unreachable past the grace window: the overlay is up. + Down, +} + +/// What happened between two `/api/info` replies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestartKind { + Same, + Restarted, + Upgraded, +} + +/// Compare two daemon replies. The commit decides first — a different one is +/// new code. `instance` catches a restart of the same build, and is the only +/// signal for a daemon built outside a git checkout (empty commit), which must +/// never be read as an upgrade. +pub fn compare_identity(before: &DaemonInfo, after: &DaemonInfo) -> RestartKind { + let a = before.build.commit.as_str(); + let b = after.build.commit.as_str(); + if !a.is_empty() && !b.is_empty() && a != b { + return RestartKind::Upgraded; + } + if !before.instance.is_empty() + && !after.instance.is_empty() + && before.instance != after.instance + { + return RestartKind::Restarted; + } + RestartKind::Same +} + +/// One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the +/// build carried uncommitted changes. +pub fn format_release(build: &BuildInfo) -> String { + if build.commit.is_empty() { + return "unknown release".to_string(); + } + let mut out = build.commit.clone(); + if build.commit_date.len() >= 10 { + out.push_str(" · "); + out.push_str(&build.commit_date[..10]); + } + if build.dirty { + out.push_str(" (dirty)"); + } + out +} + +/// Snapshot of the link, taken once per frame by the renderer. +#[derive(Debug, Clone)] +pub struct LinkStatus { + pub state: LinkState, + /// The release the daemon last reported. `None` until the first probe. + pub build: Option, + /// Failed probes in the current outage — the overlay counts them so a long + /// restart doesn't read as a hang. + pub attempts: u32, + /// Set once the daemon comes back on a different commit. The TUI is its own + /// binary, so it keeps running against the new daemon and shows this until + /// the user restarts it onto the new client. + pub upgraded_to: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct DaemonLink { + inner: Arc, +} + +#[derive(Debug, Default)] +struct Inner { + state: RwLock, + notify: Notify, +} + +#[derive(Debug)] +struct State { + state: LinkState, + identity: Option, + attempts: u32, + /// When the events socket dropped, for the grace window. + down_since: Option, + upgraded_to: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + state: LinkState::Connecting, + identity: None, + attempts: 0, + down_since: None, + upgraded_to: None, + } + } +} + +impl DaemonLink { + pub fn status(&self) -> LinkStatus { + let s = self.read(); + LinkStatus { + state: s.state, + build: s.identity.as_ref().map(|i| i.build.clone()), + attempts: s.attempts, + upgraded_to: s.upgraded_to.clone(), + } + } + + /// Whether the UI is locked behind the restart overlay. + pub fn is_blocking(&self) -> bool { + self.read().state == LinkState::Down + } + + /// Wait until anything about the link changes, so the render loop redraws. + pub async fn changed(&self) { + self.inner.notify.notified().await; + } + + /// The events socket dropped. Starts the grace window. + pub fn note_disconnected(&self, now: Instant) { + { + let mut s = self.write(); + if s.state == LinkState::Down { + return; + } + s.state = LinkState::Reconnecting; + s.attempts = 0; + s.down_since = Some(now); + } + self.wake(); + } + + /// The events socket came back. Does not clear the state on its own: the + /// socket carries no identity, and only a probe can say which daemon + /// answered. + pub fn note_connected(&self) { + self.wake(); + } + + /// Fold in a successful `/api/info`. A reply on a new commit is recorded as + /// an upgrade; either way the link is back online. + pub fn apply_info(&self, info: DaemonInfo) { + { + let mut s = self.write(); + if let Some(before) = &s.identity { + if compare_identity(before, &info) == RestartKind::Upgraded { + s.upgraded_to = Some(info.build.clone()); + } + } + s.identity = Some(info); + s.state = LinkState::Online; + s.attempts = 0; + s.down_since = None; + } + self.wake(); + } + + /// Fold in a failed probe. The overlay goes up once the grace window since + /// the disconnect has passed. + pub fn probe_failed(&self, now: Instant) { + { + let mut s = self.write(); + s.attempts = s.attempts.saturating_add(1); + let since = *s.down_since.get_or_insert(now); + if now.duration_since(since) >= GRACE { + s.state = LinkState::Down; + } else if s.state == LinkState::Online { + s.state = LinkState::Reconnecting; + } + } + self.wake(); + } + + /// Long-running task: keep [`LinkStatus`] true. Probes on a flat cadence + /// while the daemon is unreachable and idles while it is up. Never returns. + pub async fn run_probe_loop(self, daemon: DaemonClient) { + loop { + if self.read().state == LinkState::Online { + // A tick bounds the wait so a disconnect racing the notify + // costs a second, not the whole outage. + let _ = tokio::time::timeout(Duration::from_secs(1), self.changed()).await; + continue; + } + match daemon.daemon_info().await { + Ok(info) => self.apply_info(info), + Err(_) => { + self.probe_failed(Instant::now()); + tokio::time::sleep(POLL).await; + } + } + } + } + + fn wake(&self) { + self.inner.notify.notify_waiters(); + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, State> { + self.inner + .state + .read() + .expect("daemon link rwlock poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> { + self.inner + .state + .write() + .expect("daemon link rwlock poisoned") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(commit: &str, instance: &str) -> DaemonInfo { + DaemonInfo { + hostname: "konishi".into(), + version: "0.1.0".into(), + home_dir: "/home/naps".into(), + presets: Vec::new(), + build: BuildInfo { + commit: commit.into(), + commit_date: "2026-09-02T14:03:11Z".into(), + dirty: false, + }, + instance: instance.into(), + } + } + + #[test] + fn identity_tells_upgrade_from_restart() { + assert_eq!( + compare_identity(&info("aaa", "i1"), &info("bbb", "i2")), + RestartKind::Upgraded + ); + assert_eq!( + compare_identity(&info("aaa", "i1"), &info("aaa", "i2")), + RestartKind::Restarted + ); + assert_eq!( + compare_identity(&info("aaa", "i1"), &info("aaa", "i1")), + RestartKind::Same + ); + // No build stamp: the instance still reports the restart, and nothing + // ever claims a phantom upgrade. + assert_eq!( + compare_identity(&info("", "i1"), &info("", "i2")), + RestartKind::Restarted + ); + } + + #[test] + fn release_line_pairs_hash_with_date() { + assert_eq!( + format_release(&info("f89be4a716c7", "i1").build), + "f89be4a716c7 · 2026-09-02" + ); + assert_eq!( + format_release(&BuildInfo { + commit: "abc".into(), + commit_date: String::new(), + dirty: true, + }), + "abc (dirty)" + ); + assert_eq!(format_release(&BuildInfo::default()), "unknown release"); + } + + #[test] + fn grace_window_holds_the_overlay_back() { + let link = DaemonLink::default(); + link.apply_info(info("aaa", "i1")); + assert_eq!(link.status().state, LinkState::Online); + + let t0 = Instant::now(); + link.note_disconnected(t0); + assert_eq!(link.status().state, LinkState::Reconnecting); + assert!(!link.is_blocking()); + + link.probe_failed(t0 + Duration::from_millis(400)); + assert_eq!(link.status().state, LinkState::Reconnecting); + + link.probe_failed(t0 + GRACE); + assert_eq!(link.status().state, LinkState::Down); + assert!(link.is_blocking()); + assert_eq!(link.status().attempts, 2); + } + + #[test] + fn a_plain_restart_unlocks_without_an_upgrade_note() { + let link = DaemonLink::default(); + link.apply_info(info("aaa", "i1")); + let t0 = Instant::now(); + link.note_disconnected(t0); + link.probe_failed(t0 + GRACE); + assert!(link.is_blocking()); + + link.apply_info(info("aaa", "i2")); + assert_eq!(link.status().state, LinkState::Online); + assert!(link.status().upgraded_to.is_none()); + } + + #[test] + fn an_upgrade_unlocks_and_names_the_new_release() { + let link = DaemonLink::default(); + link.apply_info(info("aaa", "i1")); + let t0 = Instant::now(); + link.note_disconnected(t0); + link.probe_failed(t0 + GRACE); + + link.apply_info(info("bbb", "i2")); + let status = link.status(); + assert_eq!(status.state, LinkState::Online); + assert_eq!(status.upgraded_to.unwrap().commit, "bbb"); + assert!(!link.is_blocking()); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 9b1ccca..66236b0 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod catalog; pub mod cli; pub mod config; +pub mod daemon_link; pub mod draft; pub mod emulators; pub mod fast_forward; @@ -33,6 +34,7 @@ use ratatui::Terminal; use crate::app::App; use crate::catalog::CatalogStore; use crate::config::TuiConfig; +use crate::daemon_link::DaemonLink; /// Inputs for [`run`]. Built by the umbrella binary from the parsed /// `TuiArgs` plus any global flags. @@ -94,8 +96,15 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { let ws_url = daemon .events_url() .map_err(|e| TuiError::Config(e.to_string()))?; + // The link watches the same daemon over `/api/info`; the events socket + // reports its own connects and disconnects into it (`docs/RESTART.md`). + let link = DaemonLink::default(); let bg_catalog = catalog.clone(); - tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await }); + let ws_link = link.clone(); + tokio::spawn(async move { bg_catalog.run_events_loop_reporting(ws_url, ws_link).await }); + let probe_link = link.clone(); + let probe_daemon = daemon.clone(); + tokio::spawn(async move { probe_link.run_probe_loop(probe_daemon).await }); // Wait briefly for the bootstrap Snapshot to land. If it doesn't, we // still enter the UI — the sidebar will populate when events flow. @@ -107,7 +116,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { let backend = CrosstermBackend::new(std::io::stdout()); let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?; - let app = App::new(tui_cfg, catalog, daemon); + let app = App::new(tui_cfg, catalog, daemon, link); app.run(&mut terminal).await } diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index a3140f1..776ddc3 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,5 +1,5 @@ //! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help -//! overlay, transient toast. +//! overlay, restart overlay, transient toast. //! //! All overlays are centered popups drawn over the rest of the UI; the //! `Clear` widget wipes the underlying buffer before drawing. @@ -33,6 +33,42 @@ impl SpawnModal { } } +/// Shown while the daemon is unreachable past the grace window, over +/// everything else, with the keyboard locked (`docs/RESTART.md`). Nothing to +/// dismiss: it clears itself when a probe succeeds. +#[derive(Debug, Clone)] +pub struct RestartOverlay { + /// Failed probes so far, so a long restart doesn't read as a hang. + pub attempts: u32, + /// The release the daemon was running before it went away. + pub release: String, +} + +impl RestartOverlay { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = centered(parent, 54, 7); + f.render_widget(Clear, area); + let block = Block::default() + .title(" daemon restarting ") + .borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let attempts = if self.attempts <= 1 { + String::new() + } else { + format!(" ({} attempts)", self.attempts) + }; + let lines = vec![ + Line::from("Waiting for the daemon to come back…"), + Line::from(format!("Polling /api/info{attempts}")), + Line::from(""), + Line::from(format!("was: {}", self.release)), + Line::from("Sessions are held by tmux and survive the restart."), + ]; + f.render_widget(Paragraph::new(lines), inner); + } +} + #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 171b19d..db547f9 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -3,14 +3,17 @@ //! hit-test mouse clicks. use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::Modifier; +use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::Frame; +use maestro_protocol::ActivityState; + +use crate::daemon_link::{format_release, LinkState, LinkStatus}; use crate::input::Region; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, SpawnModal, Toast}; +use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, RestartOverlay, SpawnModal, Toast}; use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; @@ -83,6 +86,9 @@ pub struct RenderInputs<'a> { pub help: &'a HelpOverlay, /// Edit buffer behind the rename prompt (#125). pub rename_input: &'a crate::text_input::TextInput, + /// Liveness + release of the daemon, for the status bar's indicator and the + /// restart overlay (`docs/RESTART.md`). + pub link: &'a crate::daemon_link::LinkStatus, } /// Draw one frame. Returns the `RegionMap` the input router will consult for @@ -145,7 +151,7 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { // Status bar — always visible, never eclipsed by overlays so the user can // always see the current mode + key hints. - render_status_bar(f, status_area, inputs.state); + render_status_bar(f, status_area, inputs.state, inputs.link); match inputs.state.overlay() { Some(OverlayKind::Spawn) => { @@ -203,6 +209,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { None => {} } + // Last, so it covers whatever else is up: while the daemon is gone none of + // it can act (`docs/RESTART.md`). + if inputs.link.state == LinkState::Down { + RestartOverlay { + attempts: inputs.link.attempts, + release: inputs + .link + .build + .as_ref() + .map(format_release) + .unwrap_or_else(|| "unknown release".to_string()), + } + .render(f, area); + map.modal = Some(area); + } + map } @@ -220,7 +242,16 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) { /// Render the always-visible status bar at the bottom of the frame. Surfaces /// mode + focused panel + the most useful key hints so the user is never /// guessing what state they're in. -fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { +fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) { + // The daemon indicator is pinned right and sized to its own text, so a + // narrow terminal truncates the key hints rather than the release. + let label = link_label(link); + let indicator_w = (label.chars().count() as u16).saturating_add(3); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(indicator_w)]) + .split(area); + let mode_label = match state.mode { Mode::Normal => "[NORMAL]", Mode::Insert => "[INSERT]", @@ -238,7 +269,42 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { Span::styled(format!(" {mode_label} "), mode_style), Span::raw(format!(" {focus_label} • {hint}")), ]); - f.render_widget(Paragraph::new(line), area); + f.render_widget(Paragraph::new(line), cols[0]); + + let indicator = ratatui::text::Line::from(vec![ + Span::styled(" ●", Style::default().fg(link_colour(link.state))), + Span::raw(format!(" {label} ")), + ]); + f.render_widget(Paragraph::new(indicator), cols[1]); +} + +/// The status dot's colour: the same three the sidebar cards use for session +/// activity, so "green is fine, red is broken" reads the same everywhere. +fn link_colour(state: LinkState) -> ratatui::style::Color { + let card = palette::active().card; + match state { + LinkState::Online => card.activity(ActivityState::Active), + LinkState::Connecting | LinkState::Reconnecting => { + card.activity(ActivityState::AwaitingInput) + } + LinkState::Down => card.activity(ActivityState::Error), + } +} + +/// What sits next to the dot. The browser shows the release on hover; a +/// terminal has no hover, so it is always on the line — replaced by the state +/// while the daemon is not answering, which is the more urgent fact. +fn link_label(link: &LinkStatus) -> String { + match link.state { + LinkState::Connecting => "connecting…".to_string(), + LinkState::Reconnecting => "reconnecting…".to_string(), + LinkState::Down => "daemon unreachable".to_string(), + LinkState::Online => match (&link.upgraded_to, &link.build) { + (Some(build), _) => format!("updated: {} (restart tui)", format_release(build)), + (None, Some(build)) => format_release(build), + (None, None) => "unknown release".to_string(), + }, + } } #[cfg(test)] @@ -246,6 +312,16 @@ mod tests { use super::*; use crate::input::Region; + /// A link that answered — the normal case every other render test assumes. + fn online_link() -> LinkStatus { + LinkStatus { + state: LinkState::Online, + build: None, + attempts: 0, + upgraded_to: None, + } + } + #[test] fn region_map_resolves_known_areas() { let map = RegionMap { @@ -331,6 +407,96 @@ mod tests { ); } + fn frame_text(link: &LinkStatus) -> String { + use crate::overlays::HelpOverlay; + use std::collections::HashSet; + + let sidebar = Sidebar { + sessions: Vec::new(), + highlight: None, + attached: HashSet::new(), + }; + let help = HelpOverlay { lines: Vec::new() }; + let state = AppState::new(); + let inputs = RenderInputs { + sidebar: &sidebar, + home_dir: "", + pane_title: "session", + pane: None, + draft: None, + state: &state, + help: &help, + rename_input: &crate::text_input::TextInput::default(), + link, + }; + let backend = ratatui::backend::TestBackend::new(120, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw(f, inputs); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + buf.content() + .iter() + .map(|c| c.symbol()) + .collect::>() + .join("") + } + + #[test] + fn the_status_bar_names_the_release_the_daemon_is_running() { + let text = frame_text(&LinkStatus { + state: LinkState::Online, + build: Some(maestro_protocol::BuildInfo { + commit: "f89be4a716c7".into(), + commit_date: "2026-09-02T14:03:11Z".into(), + dirty: false, + }), + attempts: 0, + upgraded_to: None, + }); + assert!(text.contains("f89be4a716c7"), "{text}"); + assert!(text.contains("2026-09-02"), "{text}"); + } + + #[test] + fn an_unreachable_daemon_covers_the_frame_with_the_restart_overlay() { + let up = frame_text(&LinkStatus { + state: LinkState::Online, + build: None, + attempts: 0, + upgraded_to: None, + }); + assert!(!up.contains("daemon restarting")); + + let down = frame_text(&LinkStatus { + state: LinkState::Down, + build: None, + attempts: 4, + upgraded_to: None, + }); + assert!(down.contains("daemon restarting"), "{down}"); + assert!(down.contains("(4 attempts)"), "{down}"); + assert!(down.contains("daemon unreachable"), "{down}"); + } + + #[test] + fn an_upgraded_daemon_tells_the_user_to_restart_the_tui() { + let text = frame_text(&LinkStatus { + state: LinkState::Online, + build: Some(maestro_protocol::BuildInfo::default()), + attempts: 0, + upgraded_to: Some(maestro_protocol::BuildInfo { + commit: "bbb111222333".into(), + commit_date: "2026-09-03T09:00:00Z".into(), + dirty: false, + }), + }); + assert!(text.contains("updated: bbb111222333"), "{text}"); + assert!(text.contains("restart tui"), "{text}"); + } + /// The spawn form is a modal: the frame shows it over whatever the pane /// was displaying, framed by its own border. #[test] @@ -356,6 +522,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); @@ -408,6 +575,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); @@ -447,6 +615,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); diff --git a/docs/RESTART.md b/docs/RESTART.md index 4441951..e6f7286 100644 --- a/docs/RESTART.md +++ b/docs/RESTART.md @@ -49,18 +49,24 @@ 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 | +| 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 | +| `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 | + +`updating` is web-only, because reloading is web-only. The TUI goes straight +back to `online` after an upgrade and puts `updated: (restart tui)` on +its status bar instead, until the user restarts it. 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. +would act on a daemon that is not there. In the TUI the quit binding still +resolves, so a blocked client is never a trapped one. It clears itself the +moment a probe succeeds — there is no "retry" button, because the client is +already retrying. ## Timing From 247060de6814305a5efe392bbbac88707113c87d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 15:56:35 +0100 Subject: [PATCH 4/5] feat(maestro): open the spawn form on the project picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker now leads the form in both clients and takes focus on open, so Tab walks from it into the command tiles. Opening the form also preselects the project of the session you were last on — a worktree session seeds its base repository — so 'n' then Enter spawns another session there. --- crates/maestro-tui/src/app.rs | 64 +++++++++++- crates/maestro-tui/src/draft.rs | 180 +++++++++++++++++++++++--------- web/src/main.ts | 11 +- web/src/spawnForm.ts | 22 ++-- web/src/spawnFormEnter.test.ts | 25 ++++- web/src/spawnModal.test.ts | 10 ++ web/src/spawnModal.ts | 3 + web/src/spawnSimilar.test.ts | 22 +++- web/src/spawnSimilar.ts | 14 +++ 9 files changed, 285 insertions(+), 66 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22d88d4..8ebfc6e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -572,9 +572,27 @@ impl App { Some(None) } - /// Put the spawn form up with a fresh draft behind it. + /// Put the spawn form up with a fresh draft behind it, preselecting the + /// project of the session the operator was last on — so `n` then Enter + /// spawns another session there, the way "spawn similar" would. A + /// worktree session seeds its *base repository*, not its checkout: the + /// daemon never adopts an existing worktree (docs/WORKTREES.md), same + /// rule as web/src/spawnSimilar.ts. fn open_spawn_modal(&mut self) { - self.draft = Some(DraftPane::default()); + let seed_cwd = self.state.active_session().and_then(|sid| { + self.catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .map(|s| match &s.worktree { + Some(wt) => wt.base_repo.clone(), + None => s.cwd.clone(), + }) + }); + let mut draft = DraftPane::default(); + draft.seed_cwd = seed_cwd; + self.draft = Some(draft); self.state.open_spawn_modal(); } @@ -831,6 +849,9 @@ impl App { return; }; draft.home_dir = home_dir; + // The fast path resolved its own project; its pick wins over the + // focused session's, so the seed never lands on top of it. + draft.seed_cwd = None; if !presets.is_empty() { draft.set_presets(presets); } @@ -1640,6 +1661,41 @@ mod tests { assert_eq!(draft.command(), "claude"); } + /// Opening the form preselects the project of the session in the pane, so + /// `n` then Enter spawns another session there. + #[test] + fn opening_the_spawn_modal_seeds_the_focused_sessions_project() { + use maestro_protocol::{CatalogEvent, SessionWorktree}; + + let sid = Uuid::from_u128(21); + let mut app = make_app(); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![test_session(sid, "/repos/maestro")], + }); + app.state.attach_to_focused(sid); + app.dispatch(Action::OpenSpawnModal); + assert_eq!( + app.draft.as_ref().unwrap().seed_cwd.as_deref(), + Some(std::path::Path::new("/repos/maestro")) + ); + + // A worktree session seeds its base repository: the daemon never + // adopts an existing worktree (docs/WORKTREES.md). + let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn"); + wt_session.worktree = Some(SessionWorktree { + base_repo: "/repos/maestro".into(), + path: "/repos/maestro/worktrees/flugelhorn".into(), + }); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![wt_session], + }); + app.dispatch(Action::OpenSpawnModal); + assert_eq!( + app.draft.as_ref().unwrap().seed_cwd.as_deref(), + Some(std::path::Path::new("/repos/maestro")) + ); + } + /// A valid form flows through: pick a project (set directly — the picker /// itself is covered in `draft.rs`), select the custom tile, type a /// command, then submit from the Spawn row. @@ -1653,11 +1709,11 @@ mod tests { draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.selected_preset = draft.presets.len() - 1; // custom - app.handle_draft_input(&InputKey::Tab); // preset → custom command + app.handle_draft_input(&InputKey::Tab); // dir picker → preset + app.handle_draft_input(&InputKey::Tab); // → custom command for c in "claude".chars() { app.handle_draft_input(&InputKey::char(c)); } - app.handle_draft_input(&InputKey::Tab); // → dir picker app.handle_draft_input(&InputKey::Tab); // → name for c in "crimson-otter".chars() { app.handle_draft_input(&InputKey::char(c)); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index f8d941a..c9077b4 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -221,6 +221,11 @@ pub struct DraftPane { pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, + /// Directory to preselect once the project scan lands — the project of + /// the session the operator was last looking at, so `n` then Enter + /// respawns there without touching the picker. Consumed by the first + /// [`Self::set_projects`]; `None` once applied or when nothing seeded it. + pub seed_cwd: Option, /// The repository picked in step one ([`group_key`] of its main box), /// `None` until a box is picked. Looked up fresh in `groups` rather than /// cached, so a project-list refresh can't leave it stale. @@ -293,6 +298,7 @@ impl Default for DraftPane { cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, + seed_cwd: None, selected_group_key: None, worktree_choice: WorktreeChoice::New, name: crate::text_input::TextInput::default(), @@ -315,7 +321,9 @@ impl Default for DraftPane { branches_for: None, needs_branches: false, error: None, - focused_field: DraftField::Preset, + // The picker owns the keyboard on open: typing filters projects + // straight away, and Tab from here reaches the command tiles. + focused_field: DraftField::DirPicker, home_dir: String::new(), } } @@ -358,6 +366,26 @@ impl DraftPane { }) .collect(); self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1)); + self.apply_seed_cwd(); + } + + /// Preselect the seeded directory now that the scan has landed: highlight + /// its repository box and pick it, so Enter from the untouched picker + /// spawns there. A seed no box matches is dropped — the operator picks by + /// hand, exactly as before. + fn apply_seed_cwd(&mut self) { + let Some(seed) = self.seed_cwd.take() else { + return; + }; + let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| { + let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed); + matches.then(|| (i, (*g).clone())) + }); + let Some((index, group)) = hit else { + return; + }; + self.highlighted = index; + self.select_group(&group); } /// Fill the local name batch from `GET /api/names`, seeding the field @@ -705,14 +733,14 @@ impl DraftPane { } fn field_order(&self) -> Vec { - let mut order = vec![DraftField::Preset]; - if self.is_custom() { - order.push(DraftField::CustomCommand); - } - order.push(DraftField::DirPicker); + let mut order = vec![DraftField::DirPicker]; if self.cwd_raw_visible { order.push(DraftField::CwdRaw); } + order.push(DraftField::Preset); + if self.is_custom() { + order.push(DraftField::CustomCommand); + } order.push(DraftField::Name); order.push(DraftField::Advanced); if !self.advanced_collapsed { @@ -1116,15 +1144,17 @@ impl DraftPane { + u16::from(self.error.is_some()); let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize; - let mut constraints = vec![Constraint::Length(1)]; - if show_custom { - constraints.push(Constraint::Length(1)); - } - constraints.push(Constraint::Length(1)); + // The picker leads the form: it is where focus lands on open, and Tab + // walks down from it into the command tiles. + let mut constraints = vec![Constraint::Length(1)]; // dir filter constraints.push(Constraint::Length(list_capacity as u16)); if show_raw { constraints.push(Constraint::Length(1)); } + constraints.push(Constraint::Length(1)); // command tiles + if show_custom { + constraints.push(Constraint::Length(1)); + } constraints.push(Constraint::Length(1)); // name constraints.push(Constraint::Length(1)); // advanced header if show_wt_choice { @@ -1163,36 +1193,6 @@ impl DraftPane { // Tracks where the terminal cursor belongs for the focused text field. let mut cursor: Option<(u16, u16)> = None; - // -- command presets --------------------------------------------------- - let tiles_row = next_row(); - let mut tiles = vec![Span::raw(format!("{: 0 { - tiles.push(Span::raw(" ")); - } - let mut style = Style::default(); - if i == self.selected_preset { - style = style.add_modifier(Modifier::REVERSED); - } - tiles.push(Span::styled(format!("[{}]", preset.label), style)); - } - f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row); - - // -- custom command (only while a custom tile is selected) ------------- - if show_custom { - let row = next_row(); - draw_gutter(f, row, ""); - let col = draw_text_field( - f, - &self.custom_command, - row, - field_style(DraftField::CustomCommand), - ); - if self.focused_field == DraftField::CustomCommand { - cursor = Some((col, row.y)); - } - } - // -- directory picker ---------------------------------------------------- let filter_row = next_row(); draw_gutter(f, filter_row, "dir:"); @@ -1280,6 +1280,36 @@ impl DraftPane { } } + // -- command presets --------------------------------------------------- + let tiles_row = next_row(); + let mut tiles = vec![Span::raw(format!("{: 0 { + tiles.push(Span::raw(" ")); + } + let mut style = Style::default(); + if i == self.selected_preset { + style = style.add_modifier(Modifier::REVERSED); + } + tiles.push(Span::styled(format!("[{}]", preset.label), style)); + } + f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row); + + // -- custom command (only while a custom tile is selected) ------------- + if show_custom { + let row = next_row(); + draw_gutter(f, row, ""); + let col = draw_text_field( + f, + &self.custom_command, + row, + field_style(DraftField::CustomCommand), + ); + if self.focused_field == DraftField::CustomCommand { + cursor = Some((col, row.y)); + } + } + // -- name + reroll --------------------------------------------------------- let name_row = next_row(); draw_gutter(f, name_row, "name:"); @@ -1681,7 +1711,10 @@ mod tests { #[test] fn left_right_cycles_presets_wrapping_into_custom() { - let mut draft = DraftPane::default(); + let mut draft = DraftPane { + focused_field: DraftField::Preset, + ..DraftPane::default() + }; draft.on_input(&InputKey::Right); assert_eq!(draft.command(), "opencode"); draft.on_input(&InputKey::Left); @@ -1704,17 +1737,22 @@ mod tests { draft.selected_preset = draft.presets.len() - 1; assert!(draft.is_custom()); assert_eq!( - draft.field_order()[..2], - [DraftField::Preset, DraftField::CustomCommand], - "custom command sits between presets and the dir picker" + draft.field_order()[..3], + [ + DraftField::DirPicker, + DraftField::Preset, + DraftField::CustomCommand + ], + "the picker leads; custom command sits right after the tiles" ); + draft.next_field(); // DirPicker → Preset draft.next_field(); assert_eq!(draft.focused_field, DraftField::CustomCommand); typed(&mut draft, "bash -l"); assert_eq!(draft.command(), "bash -l"); // Tab walks past the hidden fields in order. draft.next_field(); - assert_eq!(draft.focused_field, DraftField::DirPicker); + assert_eq!(draft.focused_field, DraftField::Name); } #[test] @@ -1777,8 +1815,11 @@ mod tests { ], Utc::now(), ); - draft.next_field(); // Preset → DirPicker - assert_eq!(draft.focused_field, DraftField::DirPicker); + assert_eq!( + draft.focused_field, + DraftField::DirPicker, + "the form opens with the project filter focused" + ); typed(&mut draft, "maes"); assert_eq!( draft.visible_groups().len(), @@ -1890,6 +1931,47 @@ mod tests { assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit); } + #[test] + fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() { + let mut draft = DraftPane { + seed_cwd: Some(PathBuf::from("/repos/other")), + ..DraftPane::default() + }; + draft.set_projects( + vec![ + project("maestro", "/repos/maestro", 1, Some("claude")), + project("other", "/repos/other", 2, Some("zsh")), + ], + Utc::now(), + ); + assert_eq!( + draft.picked_cwd.as_deref(), + Some(Path::new("/repos/other")), + "the seeded project is picked before a key is pressed" + ); + assert_eq!(draft.selection(), 1, "and its row is the highlighted one"); + assert_eq!( + draft.on_input(&InputKey::Enter), + DraftInputResult::Submit, + "one Enter spawns in the seeded project" + ); + assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other"))); + } + + #[test] + fn a_seed_no_project_matches_leaves_the_picker_untouched() { + let mut draft = DraftPane { + seed_cwd: Some(PathBuf::from("/gone")), + ..DraftPane::default() + }; + draft.set_projects( + vec![project("maestro", "/repos/maestro", 1, None)], + Utc::now(), + ); + assert_eq!(draft.picked_cwd, None); + assert_eq!(draft.selection(), 0); + } + #[test] fn dir_picker_enter_on_other_directory_never_submits_directly_97() { let mut draft = DraftPane::default(); @@ -2140,7 +2222,7 @@ mod tests { let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); let pos = terminal.get_cursor_position().unwrap(); - assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1)); + assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0)); } #[test] diff --git a/web/src/main.ts b/web/src/main.ts index d9a0e60..db4541e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -22,7 +22,7 @@ import { } from "./fastForward.js"; import { FALLBACK_PRESETS } from "./spawnForm.js"; import { openSpawnModal } from "./spawnModal.js"; -import { spawnSimilarSeed } from "./spawnSimilar.js"; +import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; @@ -243,7 +243,14 @@ function requestOpenSpawn(): void { // (#100). "Spawn similar" and the fast-forward key below deliberately keep // the old behaviour: both act on the session in front of you, so a split // beside it is the point. - openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) }); + // The session you were last on preselects its project: `n` then Enter spawns + // another session there without touching the picker. + const focused = mv?.focusedSessionId() ?? null; + const current = focused ? store.snapshot().sessions.get(focused) : undefined; + openSpawnModal({ + seed: current ? spawnSimilarProjectSeed(current) : undefined, + onSuccess: (session) => mountSpawned(session, { newTab: true }), + }); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 5202db3..1d81a24 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -122,12 +122,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const form = document.createElement("form"); form.className = "spawn-form"; form.innerHTML = ` -
- Command -
- -
Project
+
+ Command +
+ +
Name
@@ -896,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { e.preventDefault(); if (target === filterInput) { - const top = visibleGroups(filterInput.value)[0]; - if (top && top.main.path !== selectedGroup?.main.path) selectProject(top); + // Resolve the filter to a project only when the current pick isn't one + // of the matches: a filter typed past it means the operator moved on, + // while a pick that still matches (including the seeded one the form + // opens with) keeps its worktree choice. + const visible = visibleGroups(filterInput.value); + const stillMatches = + selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path); + if (!stillMatches && visible[0]) selectProject(visible[0]); } form.requestSubmit(); }); diff --git a/web/src/spawnFormEnter.test.ts b/web/src/spawnFormEnter.test.ts index d856603..638a927 100644 --- a/web/src/spawnFormEnter.test.ts +++ b/web/src/spawnFormEnter.test.ts @@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; - async function mount(projects: Project[]): Promise { + async function mount( + projects: Project[], + seed?: Parameters[0]["seed"], + ): Promise { stubFetch(projects); - handle = createSpawnForm({ onSuccess() {} }); + handle = createSpawnForm({ seed, onSuccess() {} }); document.body.appendChild(handle.el); await drain(); return handle.el; @@ -232,6 +235,24 @@ describe("spawnForm Enter-to-submit (#97)", () => { expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); }); + it("submits in the seeded project rather than the freshest one", async () => { + // What `n` then Enter must do: the modal opens parked on the project of + // the session you were last on (OTHER here), even though MAESTRO's + // worktree is the freshest row in the list. + const form = await mount([MAESTRO, WT_A, OTHER], { + cwd: OTHER.path, + cwdRawVisible: false, + }); + expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector(".project-filter")!); + + expect(spy).toHaveBeenCalledTimes(1); + expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); + }); + it("does not clobber an already-chosen worktree when the filter still matches it", async () => { const form = await mount([MAESTRO, WT_A]); box(form, MAESTRO.path).click(); diff --git a/web/src/spawnModal.test.ts b/web/src/spawnModal.test.ts index eb7a15f..0184a5e 100644 --- a/web/src/spawnModal.test.ts +++ b/web/src/spawnModal.test.ts @@ -52,6 +52,16 @@ describe("spawn modal", () => { expect(host.querySelector(".form-actions .cancel")).toBeTruthy(); }); + it("opens with the project filter focused, ahead of the command tiles", async () => { + openSpawnModal({ onSuccess: () => {} }); + await flush(); + + const form = modal()!.querySelector("form.spawn-form")!; + expect(document.activeElement).toBe(form.querySelector(".project-filter")); + const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent); + expect(fields.slice(0, 2)).toEqual(["Project", "Command"]); + }); + it("titles a seeded open as a clone", async () => { openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} }); await flush(); diff --git a/web/src/spawnModal.ts b/web/src/spawnModal.ts index 0093c1a..b90af6c 100644 --- a/web/src/spawnModal.ts +++ b/web/src/spawnModal.ts @@ -59,6 +59,9 @@ export function openSpawnModal(opts: SpawnModalOptions): void { }, }); dialog.appendChild(form.el); + // The project filter owns the keyboard on open: typing narrows the list and + // Tab from here reaches the command tiles, matching the TUI's draft pane. + form.el.querySelector(".project-filter")?.focus(); function close(): void { if (closeCurrent === close) closeCurrent = null; diff --git a/web/src/spawnSimilar.test.ts b/web/src/spawnSimilar.test.ts index e80e9ac..f23320f 100644 --- a/web/src/spawnSimilar.test.ts +++ b/web/src/spawnSimilar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { spawnSimilarSeed } from "./spawnSimilar.js"; +import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js"; import type { Session } from "./types.js"; function session(overrides: Partial = {}): Session { @@ -43,3 +43,23 @@ describe("spawnSimilarSeed (#28)", () => { expect(seed.name).toBeUndefined(); }); }); + +describe("spawnSimilarProjectSeed", () => { + it("seeds the project alone, leaving command and worktree at their defaults", () => { + const seed = spawnSimilarProjectSeed(session()); + expect(seed.cwd).toBe("/repos/app"); + expect(seed.cwdRawVisible).toBe(false); + expect(seed.command).toBeUndefined(); + expect(seed.worktreeEnabled).toBeUndefined(); + }); + + it("seeds a worktree session's base repository", () => { + const seed = spawnSimilarProjectSeed( + session({ + cwd: "/repos/app-worktrees/feature", + worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" }, + }), + ); + expect(seed.cwd).toBe("/repos/app"); + }); +}); diff --git a/web/src/spawnSimilar.ts b/web/src/spawnSimilar.ts index 4b3abbb..74c51b9 100644 --- a/web/src/spawnSimilar.ts +++ b/web/src/spawnSimilar.ts @@ -28,3 +28,17 @@ export function spawnSimilarSeed(s: Session): SpawnFormSeed { cwdRawVisible: false, }; } + +/** The project half of a clone: the session's repository, nothing else. + * + * The plain spawn modal opens on this so the picker is already parked on the + * project you were last looking at — one Enter spawns there — while the + * command, worktree decision and name stay at the form's own defaults. Same + * base-repository rule as `spawnSimilarSeed`. */ +export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed { + const wt = s.worktree ?? null; + return { + cwd: wt ? wt.base_repo : s.cwd, + cwdRawVisible: false, + }; +} From 3ce1a6f3319a3b00205f3daeb8ea6bfc6efc6667 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 16:01:08 +0100 Subject: [PATCH 5/5] feat(maestro): add paging and symbol keys to the mobile key bar PgUp/PgDn and the `- / | ~` symbols the soft keyboard buries behind a page switch join the bar, in an 8-column grid that keeps the cursor inverted-T. Columns hold a thumb's width, so a narrow phone scrolls the bar sideways instead of shrinking the caps. Closes #7 --- web/src/attach.ts | 39 ++++++++++++++++++++++++++++++++------- web/src/keys.test.ts | 15 ++++++++++++++- web/src/keys.ts | 16 ++++++++++++++-- web/src/styles.css | 23 ++++++++++++++++------- 4 files changed, 76 insertions(+), 17 deletions(-) diff --git a/web/src/attach.ts b/web/src/attach.ts index 95d05dc..ec7d236 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; -import { applyModsToChar, cursorSeq } from "./keys.js"; +import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { requestToggleDrawer } from "./drawer.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; @@ -433,11 +433,17 @@ export function createAttachConnection( * - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt). * - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a * pending modifier can promote it to the `\x1b[1;` form. + * - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its + * CSI parameter, whose modified form is `\x1b[;~`. + * - `char` is a literal character (the symbols the soft keyboard buries behind + * a page switch), folded through a pending modifier like typed input. * - `seq` is a plain key (Esc, Tab) sent verbatim. */ type KeyDef = | { label: string; mod: Modifier } | { label: string; cursor: CursorFinal } + | { label: string; tilde: number } + | { label: string; char: string } | { label: string; seq: string }; type Modifier = "ctrl" | "alt"; @@ -445,14 +451,17 @@ type Modifier = "ctrl" | "alt"; type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F"; /** - * The keys the mobile soft keyboard hides, ordered to flow row-major into a - * 5-column / 2-row grid (NAP-342 review): + * The keys the mobile soft keyboard hides, ordered to flow row-major into an + * 8-column / 2-row grid (#7): * - * Esc Tab Home ↑ End - * Ctrl Alt ← ↓ → + * Esc Tab Home ↑ End PgUp - / + * Ctrl Alt ← ↓ → PgDn | ~ * - * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as - * a real-keyboard inverted-T on the right, with Home/End filling its top corners. + * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a + * real-keyboard inverted-T in the middle with Home/End filling its top corners, + * then the paging pair and the shell symbols that cost a soft-keyboard page + * switch to reach. Narrower screens scroll the bar sideways rather than + * shrinking the caps below a thumb's width. */ const KEY_DEFS: KeyDef[] = [ { label: "Esc", seq: "\x1b" }, @@ -460,11 +469,17 @@ const KEY_DEFS: KeyDef[] = [ { label: "Home", cursor: "H" }, { label: "↑", cursor: "A" }, { label: "End", cursor: "F" }, + { label: "PgUp", tilde: 5 }, + { label: "-", char: "-" }, + { label: "/", char: "/" }, { label: "Ctrl", mod: "ctrl" }, { label: "Alt", mod: "alt" }, { label: "←", cursor: "D" }, { label: "↓", cursor: "B" }, { label: "→", cursor: "C" }, + { label: "PgDn", tilde: 6 }, + { label: "|", char: "|" }, + { label: "~", char: "~" }, ]; /** @@ -521,6 +536,16 @@ function buildKeyBar(send: (data: string) => void): KeyBar { send(cursorSeq(def.cursor, armed.ctrl, armed.alt)); clearMods(); }); + } else if ("tilde" in def) { + btn.addEventListener("click", () => { + send(tildeSeq(def.tilde, armed.ctrl, armed.alt)); + clearMods(); + }); + } else if ("char" in def) { + btn.addEventListener("click", () => { + send(applyModsToChar(def.char, armed.ctrl, armed.alt)); + clearMods(); + }); } else { btn.addEventListener("click", () => { send(def.seq); diff --git a/web/src/keys.test.ts b/web/src/keys.test.ts index b4915e6..4205c53 100644 --- a/web/src/keys.test.ts +++ b/web/src/keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyModsToChar, cursorSeq, toCtrl } from "./keys.js"; +import { applyModsToChar, cursorSeq, tildeSeq, toCtrl } from "./keys.js"; describe("toCtrl", () => { it("maps letters to their control code, case-insensitively", () => { @@ -43,3 +43,16 @@ describe("cursorSeq", () => { expect(cursorSeq("A", true, true)).toBe("\x1b[1;7A"); }); }); + +describe("tildeSeq", () => { + it("uses the bare CSI form when unmodified", () => { + expect(tildeSeq(5, false, false)).toBe("\x1b[5~"); + expect(tildeSeq(6, false, false)).toBe("\x1b[6~"); + }); + + it("keeps the parameter in place when modified, unlike the cursor keys", () => { + expect(tildeSeq(5, true, false)).toBe("\x1b[5;5~"); + expect(tildeSeq(6, false, true)).toBe("\x1b[6;3~"); + expect(tildeSeq(6, true, true)).toBe("\x1b[6;7~"); + }); +}); diff --git a/web/src/keys.ts b/web/src/keys.ts index 6ef6c6e..c14c28b 100644 --- a/web/src/keys.ts +++ b/web/src/keys.ts @@ -23,6 +23,18 @@ export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string * pending — `\x1b[1;`, n = 1 + alt(2) + ctrl(4) (xterm convention). */ export function cursorSeq(final: string, ctrl: boolean, alt: boolean): string { if (!ctrl && !alt) return `\x1b[${final}`; - const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0); - return `\x1b[1;${n}${final}`; + return `\x1b[1;${modParam(ctrl, alt)}${final}`; +} + +/** Modifier parameter of the xterm CSI forms: n = 1 + alt(2) + ctrl(4). */ +function modParam(ctrl: boolean, alt: boolean): number { + return 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0); +} + +/** Sequence for the `~`-terminated navigation keys (PgUp `5`, PgDn `6`), which + * carry modifiers as `\x1b[;~` rather than the cursor keys' `1;` + * form. */ +export function tildeSeq(code: number, ctrl: boolean, alt: boolean): string { + if (!ctrl && !alt) return `\x1b[${code}~`; + return `\x1b[${code};${modParam(ctrl, alt)}~`; } diff --git a/web/src/styles.css b/web/src/styles.css index 97d6a22..af1a73c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1499,20 +1499,29 @@ input.leaf-rename:focus { /* ---------- mobile special-key bar (Termux-style) ---------- */ -/* Two compact rows (Termux layout) in a 5-column grid the 10 keys flow into: - Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T on - the right (Home/End fill the top corners). Layout is always defined; only - *visibility* is gated: hidden on precise-pointer devices (a physical keyboard - already has these keys), shown on touch where the soft keyboard omits them. - The shell tracks the visual viewport (see `#app`), so it lands above it. */ +/* Two compact rows (Termux layout) in an 8-column grid the 16 keys flow into: + Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T in + the middle (Home/End fill the top corners), then PgUp/PgDn and the shell + symbols. Columns never shrink below a thumb's width — on a narrow phone the + bar scrolls sideways instead. Layout is always defined; only *visibility* is + gated: hidden on precise-pointer devices (a physical keyboard already has + these keys), shown on touch where the soft keyboard omits them. The shell + tracks the visual viewport (see `#app`), so it lands above it. */ .attach-keybar { display: none; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(8, minmax(2.5rem, 1fr)); gap: var(--sp-1); padding: var(--sp-1) var(--sp-2); padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--surface); + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.attach-keybar::-webkit-scrollbar { + display: none; } /* Shown on touch only while *this* terminal is focused (`keybar-active`, toggled