From 1bb464be4bdddb7effe0bbf7008db888e66d7a0b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 10:14:10 +0100 Subject: [PATCH] refactor(maestro): flatten web UI to a single host Session tree nests by working directory only; the daemon chips in the header become a session tally, and the spawn form loses its host picker. API calls move from /api/daemons/:id/* to /api/dirs, /api/recent-dirs, /api/gitroot, and /api/info. --- crates/maestro-web/src/proxy.rs | 20 ++-- crates/maestro-web/tests/proxy.rs | 6 +- web/src/api.ts | 37 +++----- web/src/attach.ts | 26 ++--- web/src/catalog.ts | 133 ++++++++------------------ web/src/dirAutocomplete.ts | Bin 8153 -> 7842 bytes web/src/events.ts | 26 ++--- web/src/hud.ts | 1 - web/src/kit.ts | 36 +++---- web/src/main.ts | 7 +- web/src/mock.ts | 54 ++++------- web/src/spawn.ts | 153 ++++-------------------------- web/src/styles.css | 80 +--------------- web/src/types.ts | 43 +++------ 14 files changed, 155 insertions(+), 467 deletions(-) diff --git a/crates/maestro-web/src/proxy.rs b/crates/maestro-web/src/proxy.rs index f5e5133..b785d6d 100644 --- a/crates/maestro-web/src/proxy.rs +++ b/crates/maestro-web/src/proxy.rs @@ -207,12 +207,12 @@ pub async fn pump_ws(mut client: WebSocket, upstream_url: String) { Ok(pair) => pair, Err(e) => { tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed"); - // The daemon rejects broker attaches it can't route (no shared - // perimeter, peer down, unknown session — NAP-247) with a structured - // HTTP error *before* the upgrade. We've already upgraded the browser - // socket, so a bare drop reaches it as an opaque 1006 close — the - // attach pane just flashes and vanishes with no reason. Relay the - // daemon's reason as an application close frame the UI can show (#39). + // The daemon rejects an attach it can't serve (unknown session) + // with a structured HTTP error *before* the upgrade. We've already + // upgraded the browser socket, so a bare drop reaches it as an + // opaque 1006 close — the attach pane just flashes and vanishes with + // no reason. Relay the daemon's reason as an application close frame + // the UI can show (#39). let (code, reason) = upstream_close_reason(&e); let _ = client .send(AxMessage::Close(Some(axum::extract::ws::CloseFrame { @@ -397,14 +397,16 @@ mod tests { #[test] fn upstream_close_reason_extracts_daemon_message() { use tokio_tungstenite::tungstenite::{http, Error as WsError}; - let body = br#"{"error":{"code":"no_perimeter_candidate","message":"peer hasn't advertised a perimeter; T2 unavailable"}}"#.to_vec(); + let body = + br#"{"error":{"code":"unknown_session","message":"no such session on this daemon"}}"# + .to_vec(); let resp = http::Response::builder() - .status(502) + .status(404) .body(Some(body)) .unwrap(); let (code, reason) = upstream_close_reason(&WsError::Http(resp)); assert_eq!(code, CLOSE_UPSTREAM_REJECTED); - assert_eq!(reason, "peer hasn't advertised a perimeter; T2 unavailable"); + assert_eq!(reason, "no such session on this daemon"); } /// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable diff --git a/crates/maestro-web/tests/proxy.rs b/crates/maestro-web/tests/proxy.rs index 04cad09..86d1fa4 100644 --- a/crates/maestro-web/tests/proxy.rs +++ b/crates/maestro-web/tests/proxy.rs @@ -20,8 +20,8 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) { async fn list_sessions() -> Json { Json(serde_json::json!([{"id": "session-1"}])) } - async fn web_info() -> Json { - Json(serde_json::json!({"daemon_id": "mock", "hostname": "mock-host"})) + async fn daemon_info() -> Json { + Json(serde_json::json!({"hostname": "mock-host"})) } async fn echo_ws(ws: WebSocketUpgrade) -> impl IntoResponse { ws.on_upgrade(echo_handler) @@ -43,7 +43,7 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) { let app = Router::new() .route("/api/sessions", get(list_sessions)) - .route("/api/web/info", get(web_info)) + .route("/api/info", get(daemon_info)) .route("/api/attach/:id", any(echo_ws)); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/web/src/api.ts b/web/src/api.ts index d7d7f56..aa61d1d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -7,7 +7,6 @@ import type { RecentDirs, Session, Uuid, - WebInfo, } from "./types.js"; async function jsonOrError(res: Response): Promise { @@ -25,10 +24,6 @@ async function jsonOrError(res: Response): Promise { throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } -export async function listDaemons(): Promise { - return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" })); -} - export async function listSessions(): Promise { return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" })); } @@ -53,31 +48,29 @@ export async function killSession(id: Uuid): Promise { ); } -/** List one directory's immediate subdirectories on `daemonId`'s host (NAP-329). - * `path` may be empty, `~`, relative, or absolute — the daemon resolves it - * against its own home dir. */ -export async function listDir(daemonId: string, path: string): Promise { - const url = `/api/daemons/${encodeURIComponent(daemonId)}/dirs?path=${encodeURIComponent(path)}`; +/** List one directory's immediate subdirectories (NAP-329). `path` may be empty, + * `~`, relative, or absolute — the daemon resolves it against its home dir. */ +export async function listDir(path: string): Promise { + const url = `/api/dirs?path=${encodeURIComponent(path)}`; return jsonOrError(await fetch(url, { credentials: "same-origin" })); } /** Identity + spawn presets of the daemon this web app is connected to (#26). */ -export async function getWebInfo(): Promise { - return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" })); +export async function getDaemonInfo(): Promise { + return jsonOrError(await fetch("/api/info", { credentials: "same-origin" })); } -/** Recently-used working dirs on `daemonId`'s host, newest-first and deduped by - * dir (#26). Drives the spawn form's recents picker. */ -export async function recentDirs(daemonId: string): Promise { - const url = `/api/daemons/${encodeURIComponent(daemonId)}/recent-dirs`; - return jsonOrError(await fetch(url, { credentials: "same-origin" })); +/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the + * spawn form's recents picker. */ +export async function recentDirs(): Promise { + return jsonOrError(await fetch("/api/recent-dirs", { credentials: "same-origin" })); } -/** Probe the enclosing git repo of `path` on `daemonId`'s host (#26). `root` is - * absent when the path isn't inside a repo. `path` may be `~`/relative/absolute; - * the daemon resolves it against its home dir. */ -export async function gitRoot(daemonId: string, path: string): Promise { - const url = `/api/daemons/${encodeURIComponent(daemonId)}/gitroot?path=${encodeURIComponent(path)}`; +/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path + * isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves + * it against its home dir. */ +export async function gitRoot(path: string): Promise { + const url = `/api/gitroot?path=${encodeURIComponent(path)}`; return jsonOrError(await fetch(url, { credentials: "same-origin" })); } diff --git a/web/src/attach.ts b/web/src/attach.ts index b512490..39c0310 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -36,12 +36,11 @@ export interface AttachOptions { onClosed?: (sessionId: Uuid) => void; } -/** What the header shows about the session (name / command / host). Optional — - * falls back to a short session id when the session isn't known yet. */ +/** What the header shows about the session (name / command). Optional — falls + * back to a short session id when the session isn't known yet. */ export interface AttachInfo { name: string; command: string; - daemon: string; } function esc(s: string): string { @@ -53,10 +52,9 @@ function esc(s: string): string { } /** - * Same-origin broker URL. The browser loads from `maestro-web`, which + * Same-origin attach URL. The browser loads from `maestro-web`, which * reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id` - * broker endpoint (the daemon short-circuits to the local PTY when it owns the - * session, or brokers to the owning peer — NAP-247/NAP-327). + * endpoint. */ function brokerStreamUrl(sessionId: Uuid): string { const proto = location.protocol === "https:" ? "wss:" : "ws:"; @@ -70,18 +68,15 @@ export function createAttachConnection( ): AttachConnection { const el = document.createElement("div"); el.className = "attach"; - // Useful identity over a bare UUID: name · command @ host. The full id stays - // in the tooltip for when you really need it. + // Useful identity over a bare UUID: name · command. The full id stays in the + // tooltip for when you really need it. const titleHtml = info ? `${esc(info.name)}` + (info.command && info.command !== info.name ? `${esc(info.command)}` - : "") + - (info.daemon ? `${esc(info.daemon)}` : "") + : "") : `session ${esc(sessionId.slice(0, 8))}`; - const titleAttr = info - ? `${info.name} · ${info.command} @ ${info.daemon} (${sessionId})` - : sessionId; + const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId; el.innerHTML = `
← @@ -317,9 +312,8 @@ export function createAttachConnection( }; ws.onclose = (ev) => { - // The web proxy relays a broker/daemon rejection (no shared perimeter, peer - // down, unknown session) as an application close code (4000+) carrying a - // human reason. Show it in the pane — both the header and the terminal body + // The web proxy relays a daemon rejection (unknown session) as an + // application close code (4000+) carrying a human reason. Show it in the pane — both the header and the terminal body // — and KEEP the pane up so the user can read why, instead of letting it // flash and vanish (#39). Clean session-ends and ordinary drops keep the // auto-close behavior below. diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 1ec7b57..fc62910 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -1,7 +1,7 @@ -import { killSession } from "./api.js"; +import { getDaemonInfo, killSession } from "./api.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; -import type { DaemonInfo, Session } from "./types.js"; +import type { Session } from "./types.js"; import { abbreviateCwd, abbreviateFolderName, sessionCwd, sessionName } from "./types.js"; import { openSpawnModal } from "./spawn.js"; import { createThemeToggle } from "./theme.js"; @@ -29,26 +29,18 @@ function compareNodes(a: TreeNode, b: TreeNode): number { return an.localeCompare(bn); } -function buildTree( - sessions: Session[], - daemons: Map, - filter: string, -): TreeNode { +function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode { const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] }; const lc = filter.trim().toLowerCase(); for (const s of sessions) { - const home = daemons.get(s.daemon_id)?.home_dir; const cwd = sessionCwd(s); const dir = cwd ? abbreviateCwd(cwd, home) : ""; if (lc) { - const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase(); + const haystack = `${sessionName(s)} ${dir}`.toLowerCase(); if (!haystack.includes(lc)) continue; } - // Host is the first level of nesting; the working directory nests beneath - // it, so the daemon no longer needs to ride along on each leaf. - const dirParts = dir ? dir.split("/").filter(Boolean) : []; - const parts = [s.daemon_id, ...dirParts]; + const parts = dir ? dir.split("/").filter(Boolean) : []; let cursor = root; for (const part of parts) { const path = cursor.path ? `${cursor.path}/${part}` : part; @@ -69,16 +61,9 @@ function buildTree( for (const c of n.children) if (c.kind === "folder") sortRec(c); }; sortRec(root); - // Collapse linear directory chains *within* each daemon (but never the daemon - // node itself) so `~/git.naps.pt/naps62/maestro` reads as one compact folder - // rather than four nested ones. - for (const daemon of root.children) { - if (daemon.kind === "folder") { - daemon.children = daemon.children.map((c) => - c.kind === "folder" ? mergeLinear(c) : c, - ); - } - } + // Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as + // one compact folder rather than four nested ones. + root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c)); return root; } @@ -145,11 +130,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { const running = s.status === "Running"; row.dataset.status = running ? "running" : "dead"; const name = sessionName(s); - row.setAttribute( - "aria-label", - `${name}, ${running ? "running" : "exited"}, on daemon ${s.daemon_id}`, - ); - // The host is now the enclosing folder, so only surface the command — and + row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`); + // The directory is the enclosing folder, so only surface the command — and // only when it isn't already what the name shows. const showCmd = s.command !== name; row.innerHTML = ` @@ -165,8 +147,6 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - // The sidebar (which has the daemon list) opens the spawn modal pre-seeded - // from this session. document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; @@ -191,42 +171,19 @@ function escapeHtml(s: string): string { .replace(/"/g, """); } -function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElement { +function renderStatusbar(sessions: Session[]): HTMLElement { const wrap = document.createElement("div"); wrap.className = "statusbar"; - // Per-host session counts replace the old global "N sessions / N live" tally: - // each daemon chip carries its own green count, so the tally was redundant. - const counts = new Map(); - for (const s of sessions) { - counts.set(s.daemon_id, (counts.get(s.daemon_id) ?? 0) + 1); - } - - const daemonsWrap = document.createElement("div"); - daemonsWrap.className = "daemons"; - if (daemons.length === 0) { - daemonsWrap.innerHTML = `no daemons connected`; - } else { - for (const d of daemons) { - const n = counts.get(d.daemon_id) ?? 0; - const chip = document.createElement("span"); - chip.className = "daemon-chip"; - chip.title = `${d.hostname} · ${d.daemon_url} · ${n} session${n === 1 ? "" : "s"}`; - chip.innerHTML = - `${escapeHtml(d.daemon_id)}` + - `${n}`; - daemonsWrap.appendChild(chip); - } - } - wrap.appendChild(daemonsWrap); + const live = sessions.filter((s) => s.status === "Running").length; + const tally = document.createElement("span"); + tally.className = "session-tally"; + tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`; + wrap.appendChild(tally); return wrap; } -function renderEmpty( - noDaemons: boolean, - filtering: boolean, - onSpawn: () => void, -): HTMLElement { +function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement { const el = document.createElement("div"); el.className = "empty"; if (filtering) { @@ -236,14 +193,6 @@ function renderEmpty(

No sessions match your search. Clear it to see everything.

`; return el; } - if (noDaemons) { - el.classList.add("connecting"); - el.innerHTML = ` - -
Waiting for a daemon
-

No daemons are connected yet. Start a maestro daemon on a host and it will appear here.

`; - return el; - } el.innerHTML = `
No sessions yet
@@ -299,13 +248,11 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): onToggleSidebar?.(), ); - // Session tally + daemon chips live centered in the header (not the sidebar). + // Session tally lives centered in the header (not the sidebar). const statusHost = header.querySelector(".app-status") as HTMLElement; const renderStatus = () => { const state = store.snapshot(); - statusHost.replaceChildren( - renderStatusbar([...state.daemons.values()], [...state.sessions.values()]), - ); + statusHost.replaceChildren(renderStatusbar([...state.sessions.values()])); }; store.subscribe(renderStatus); renderStatus(); @@ -327,10 +274,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; - spawnBtn.addEventListener("click", () => { - const snap = store.snapshot(); - openSpawnModal(snap.daemons, undefined, snap.sessions); - }); + spawnBtn.addEventListener("click", () => openSpawnModal()); actions.appendChild(spawnBtn); return header; } @@ -359,7 +303,7 @@ export interface SidebarHandle { /** * Build the persistent session sidebar: brand, search, spawn, theme toggle, a - * live daemon/session status line, and the folder-grouped session tree. Unlike + * live session status line, and the folder-grouped session tree. Unlike * the old full-screen catalog this is mounted once by the shell and stays put * while the content pane swaps between sessions — so the store subscription and * scroll position survive navigation. The active session is highlighted. @@ -374,6 +318,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const el = document.createElement("aside"); el.className = "sidebar"; + // The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree + // renders with absolute paths until it lands. + let homeDir: string | undefined; + const searchHeader = document.createElement("header"); searchHeader.className = "sidebar-search"; searchHeader.innerHTML = ` @@ -474,14 +422,11 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const refresh = () => { const state = store.snapshot(); const sessions = [...state.sessions.values()]; - const tree = buildTree(sessions, state.daemons, filter); + const tree = buildTree(sessions, homeDir, filter); treeHost.replaceChildren(); if (sessions.length === 0) { treeHost.appendChild( - renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => { - const snap = store.snapshot(); - openSpawnModal(snap.daemons, undefined, snap.sessions); - }), + renderEmpty(filter.trim().length > 0, () => openSpawnModal()), ); return; } @@ -491,24 +436,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S applyHighlights(); }; + void getDaemonInfo() + .then((info) => { + homeDir = info.home_dir; + refresh(); + }) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + const rerenderHandler = () => refresh(); document.addEventListener("catalog-rerender", rerenderHandler); - // Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir, - // and command (matched back to a preset by the form) — but with a freshly + // Spawn similar: reopen the spawn modal pre-seeded from a session's dir and + // command (matched back to a preset by the form) — but with a freshly // generated name rather than the original's, since it's a new session. const spawnSimilarHandler = (ev: Event) => { const s = (ev as CustomEvent).detail; - const snap = store.snapshot(); - openSpawnModal( - snap.daemons, - { - daemon_id: s.daemon_id, - cwd: s.cwd, - command: s.command, - }, - snap.sessions, - ); + openSpawnModal({ cwd: s.cwd, command: s.command }); }; document.addEventListener("catalog-spawn-similar", spawnSimilarHandler); diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts index 36a03288c21b7af37724b9a33b280802716fc5ff..5e46b16374710f9a42f0adf4a8cf8719ae547c3f 100644 GIT binary patch delta 163 zcmcaUP?iJW?qSoLULkqMrtvazP>_1YLP-pW>IQ#Nq$k~#GUFJ zA8%n3&&(?*EwNJY2=Vpt1TkH6Qgc)DN+w@mf5;3}Jox~JsUXCR6ouk^g+wj|1%3U= zY@AOwn{x?sOkO5rGMQOe7_3xMxODOiVM8$cjPNVI(jq;e2@1BhwjgUa-xc}81prTw BI;#Kx delta 451 zcmZ2vd((bGqeWtBZhl^|zEx(5eoAIhaRQgVzCvnVNdZj38_#WFv*uDz$jmD!EwNJY2=Vpt1TkH6Qgc&*CIF?%9HxRmy+Fr+osgnX zoUf2L`7p;*Zv};9po2>w;@+v13bqPx%~mjzL2iU`z&0kRR70HanNrIDWM>xDCRlG? z&MCs74HE|%gyd8WO^~;M9!pcuP=qO&{E*vl@@_#B`)shiV5b3{0Jk3!HV}cQUI`DJL{cbQC663K=njMAr$uQU?V%$Ve2Y03)KbNDmY+3bwYk3P81+ JmkIyj0ss=RmRbM+ diff --git a/web/src/events.ts b/web/src/events.ts index 5aed7e5..ea24e7f 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -1,15 +1,14 @@ -import { eventsWsUrl, listDaemons, listSessions } from "./api.js"; -import type { DaemonInfo, RegistryEvent, Session, Uuid } from "./types.js"; +import { eventsWsUrl, listSessions } from "./api.js"; +import type { CatalogEvent, Session, Uuid } from "./types.js"; export interface CatalogState { - daemons: Map; sessions: Map; } export type CatalogListener = (state: CatalogState) => void; export class CatalogStore { - private state: CatalogState = { daemons: new Map(), sessions: new Map() }; + private state: CatalogState = { sessions: new Map() }; private listeners = new Set(); private ws: WebSocket | null = null; private reconnectTimer: number | null = null; @@ -46,9 +45,9 @@ export class CatalogStore { this.ws = ws; ws.onmessage = (ev) => { - let event: RegistryEvent; + let event: CatalogEvent; try { - event = JSON.parse(ev.data) as RegistryEvent; + event = JSON.parse(ev.data) as CatalogEvent; } catch { return; } @@ -83,23 +82,13 @@ export class CatalogStore { }, delay); } - private apply(event: RegistryEvent): void { + private apply(event: CatalogEvent): void { switch (event.type) { case "Snapshot": this.state = { - daemons: new Map(event.daemons.map((d) => [d.daemon_id, d])), sessions: new Map(event.sessions.map((s) => [s.id, s])), }; break; - case "DaemonAdded": - this.state.daemons.set(event.daemon.daemon_id, event.daemon); - break; - case "DaemonRemoved": - this.state.daemons.delete(event.daemon_id); - for (const [id, sess] of this.state.sessions) { - if (sess.daemon_id === event.daemon_id) this.state.sessions.delete(id); - } - break; case "SessionAdded": this.state.sessions.set(event.session.id, event.session); break; @@ -119,9 +108,8 @@ export class CatalogStore { private async resync(): Promise { try { - const [daemons, sessions] = await Promise.all([listDaemons(), listSessions()]); + const sessions = await listSessions(); this.state = { - daemons: new Map(daemons.map((d) => [d.daemon_id, d])), sessions: new Map(sessions.map((s) => [s.id, s])), }; this.emit(); diff --git a/web/src/hud.ts b/web/src/hud.ts index bbab533..9c34c1e 100644 --- a/web/src/hud.ts +++ b/web/src/hud.ts @@ -15,7 +15,6 @@ import { sessionName, type Session, type Uuid } from "./types.js"; * them. Everything else on the Session is shown, including future fields. */ const CLIENT_OWNED = new Set([ "id", - "daemon_id", "cwd", "command", "env", diff --git a/web/src/kit.ts b/web/src/kit.ts index 32a5b96..bc42371 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -55,7 +55,6 @@ function folderHeader(name: string, count: number, depth: number): HTMLElement { function leafRow(opts: { name: string; - daemon: string; cmd: string; status: "running" | "dead"; active?: boolean; @@ -192,8 +191,7 @@ export function renderKit(): HTMLElement { { const { el, body } = section("Chips & badges", "Status-bearing pills and counts."); body.append( - specimen("daemon-chip", h("span", "daemon-chip", "thinkpad")), - specimen("daemon-empty", h("span", "daemon-empty", "no daemons connected")), + specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")), specimen("folder-count", h("span", "folder-count", "4")), specimen("leaf-badge", h("span", "leaf-badge", "exited")), specimen( @@ -217,23 +215,20 @@ export function renderKit(): HTMLElement { { const { el, body } = section( "Session rows — nested by host, then directory", - "Host is the first level of nesting; the working directory (home collapsed to ~) nests beneath it, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.", + "The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.", ); const list = h("div", "kit-tree"); list.append( - folderHeader("thinkpad", 3, 0), - folderHeader("~/git/maestro", 3, 1), - folderHeader("web", 2, 2), - leafRow({ name: "redesign", daemon: "", cmd: "claude", status: "running", indent: 3 }), - leafRow({ name: "dev-server", daemon: "", cmd: "vite dev", status: "running", indent: 3, active: true }), - folderHeader("crates/registry", 1, 2), - leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }), - folderHeader("tower", 2, 0), - folderHeader("~/work/acme", 1, 1), - leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 2 }), - leafRow({ name: "scratch", daemon: "", cmd: "bash", status: "dead", indent: 1 }), - folderHeader("pi", 1, 0), - leafRow({ name: "monitor", daemon: "", cmd: "btop", status: "running", indent: 1 }), + folderHeader("~/git/maestro", 3, 0), + folderHeader("web", 2, 1), + leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }), + leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }), + folderHeader("crates/daemon", 1, 1), + leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }), + folderHeader("~/work/acme", 2, 0), + leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }), + leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }), + leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }), ); body.classList.add("kit-tree-host"); body.appendChild(list); @@ -258,12 +253,7 @@ export function renderKit(): HTMLElement {

Start your first session — it'll show up here.

`; noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session")); - const waiting = h("div", "empty connecting"); - waiting.innerHTML = ` - -
Waiting for a daemon
-

Start a maestro daemon on a host and it will appear here.

`; - body.append(welcome, noSessions, waiting); + body.append(welcome, noSessions); main.appendChild(el); } diff --git a/web/src/main.ts b/web/src/main.ts index f6b21b5..601fb2e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -176,7 +176,7 @@ function scheduleSave(): void { function infoFor(sessionId: string) { const s = store.snapshot().sessions.get(sessionId); - return s ? { name: sessionName(s), command: s.command, daemon: s.daemon_id } : undefined; + return s ? { name: sessionName(s), command: s.command } : undefined; } /** Drive the shell's responsive layout off whether any pane is open. */ @@ -200,10 +200,7 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; - spawn.addEventListener("click", () => { - const snap = store.snapshot(); - openSpawnModal(snap.daemons, undefined, snap.sessions); - }); + spawn.addEventListener("click", () => openSpawnModal()); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; } diff --git a/web/src/mock.ts b/web/src/mock.ts index 076b6e3..e3847da 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -2,7 +2,7 @@ // main.ts (`?mock`), so it never ships in a production bundle. It pushes a // synthetic Snapshot through the store's normal event path. import type { CatalogStore } from "./events.js"; -import type { ActivityState, Session, DaemonInfo } from "./types.js"; +import type { ActivityState, Session } from "./types.js"; // Rotate inferred activity across the fixture so the debug HUD (#20) shows all // three states. Keyed off the id's last hex digit for stable variety. @@ -10,7 +10,6 @@ const ACTIVITIES: ActivityState[] = ["Active", "Idle", "AwaitingInput"]; function sess( id: string, - daemon: string, command: string, status: "Running" | "Dead", cwd: string, @@ -19,7 +18,6 @@ function sess( const seed = parseInt(id.slice(-1), 16) || 0; return { id, - daemon_id: daemon, cwd, command, env: {}, @@ -48,13 +46,12 @@ function stubSpawnApi(): void { globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url; - if (path.startsWith("/api/web/info")) { + if (path.startsWith("/api/info")) { return Promise.resolve( json({ - daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", - daemon_url: "wss://thinkpad.lan/daemon", + home_dir: "/home/dev", presets: [ { id: "claude", label: "Claude", command: "claude" }, { id: "opencode", label: "opencode", command: "opencode" }, @@ -87,38 +84,31 @@ function stubSpawnApi(): void { } export function seedMock(store: CatalogStore): void { - const daemons: DaemonInfo[] = [ - { daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", daemon_url: "wss://thinkpad.lan/daemon", home_dir: "/home/dev" }, - { daemon_id: "tower", hostname: "tower.lan", version: "0.1.0", daemon_url: "wss://tower.lan/daemon", home_dir: "/home/dev" }, - { daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" }, - ]; const sessions: Session[] = [ // ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node. - sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web", "redesign"), - sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"), - sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"), - sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"), + sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"), + sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"), + sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"), + sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"), // ~/git/maestro/crates/* — sibling subtree under the same repo. - sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", "/home/dev/git/maestro/crates/registry", "registry-broker"), - sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/registry", "test-loop"), - sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"), + sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"), + sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"), + sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"), // ~/work/acme/* — a separate top-level dir on the same host. - sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", "/home/dev/work/acme/api", "billing-api"), - sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"), + sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"), + sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"), // ~/git/dotfiles — single session, fully collapsed chain. - sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"), - // pi's home (different home_dir) — sits directly under ~. - sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", "/home/pi", "monitor"), + sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"), + sess("a1b2c3d4-0000-0000-0000-000000000011", "btop", "Running", "/home/dev/git/infra", "monitor"), // outside any home dir → shown verbatim (no ~ collapse). - sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", "/etc"), - // home root on thinkpad — a leaf directly under ~ (name from command). - sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", "/home/dev"), + sess("a1b2c3d4-0000-0000-0000-000000000012", "ssh gateway", "Dead", "/etc"), + // home root — a leaf directly under ~ (name from command). + sess("a1b2c3d4-0000-0000-0000-000000000013", "htop", "Running", "/home/dev"), ]; - // The store applies RegistryEvents internally; feed a Snapshot the same way + // The store applies CatalogEvents internally; feed a Snapshot the same way // the WS would. `apply` is private, so reach it through an index cast. (store as unknown as { apply: (e: unknown) => void }).apply({ type: "Snapshot", - daemons, sessions, }); @@ -126,12 +116,6 @@ export function seedMock(store: CatalogStore): void { // backend endpoints stubbed so presets/recents/gitroot all render. if (location.search.includes("spawn")) { stubSpawnApi(); - void import("./spawn.js").then((m) => - m.openSpawnModal( - new Map(daemons.map((d) => [d.daemon_id, d])), - undefined, - new Map(sessions.map((s) => [s.id, s])), - ), - ); + void import("./spawn.js").then((m) => m.openSpawnModal()); } } diff --git a/web/src/spawn.ts b/web/src/spawn.ts index e91fbba..ca1e675 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -1,25 +1,18 @@ -import { createSession, getWebInfo, recentDirs, gitRoot } from "./api.js"; +import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js"; import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js"; import { generateName } from "./names.js"; -import type { - CreateSessionRequest, - DaemonInfo, - RecentDir, - Session, - SpawnPreset, -} from "./types.js"; +import type { CreateSessionRequest, RecentDir, SpawnPreset } from "./types.js"; import { abbreviateCwd, abbreviateFolderName } from "./types.js"; /** Seed values for the spawn form — used by "clone" to reopen the dialog with - * the same daemon, cwd, command, and name as an existing session. */ + * the same cwd, command, and name as an existing session. */ export interface SpawnPrefill { - daemon_id?: string; cwd?: string; command?: string; name?: string; } -/** Built-ins used when the daemon's `/api/web/info` presets can't be fetched +/** Built-ins used when the daemon's `/api/info` presets can't be fetched * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "claude", label: "Claude", command: "claude" }, @@ -28,14 +21,7 @@ const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "custom", label: "Custom…", command: "" }, ]; -/** How many host tiles to show before collapsing the rest behind "More…". */ -const MAX_HOST_TILES = 4; - -export function openSpawnModal( - daemons: Map, - prefill?: SpawnPrefill, - sessions?: Map, -): void { +export function openSpawnModal(prefill?: SpawnPrefill): void { const existing = document.getElementById("modal-host"); if (existing) existing.remove(); @@ -49,10 +35,6 @@ export function openSpawnModal(