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-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..6e7ba4f --- /dev/null +++ b/crates/maestro-daemon/build.rs @@ -0,0 +1,77 @@ +//! 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/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22d88d4..6ba960c 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; @@ -572,9 +601,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 +878,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); } @@ -919,6 +969,7 @@ impl App { None => "session".into(), }; + let link_status = self.link.status(); let inputs = RenderInputs { sidebar: &sidebar, home_dir: &self.home_dir, @@ -928,6 +979,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 +1228,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] @@ -1640,6 +1697,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 +1745,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)); @@ -1772,6 +1864,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/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/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 new file mode 100644 index 0000000..e6f7286 --- /dev/null +++ b/docs/RESTART.md @@ -0,0 +1,84 @@ +# 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 | + +`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. 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 + +- **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/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/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/catalog.ts b/web/src/catalog.ts index 0efa00a..d2314f6 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 { createPaletteSelect, 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 { @@ -391,6 +396,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"; @@ -618,6 +627,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/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/main.ts b/web/src/main.ts index d9a0e60..23ac046 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, @@ -22,7 +24,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"; @@ -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"; @@ -243,7 +255,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/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/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, + }; +} 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 9afeb05..de547f7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -774,6 +774,134 @@ textarea { color: var(--ink); } +/* ---------- 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; @@ -1710,20 +1838,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 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