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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -20,8 +20,8 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) {
|
||||
async fn list_sessions() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!([{"id": "session-1"}]))
|
||||
}
|
||||
async fn web_info() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({"daemon_id": "mock", "hostname": "mock-host"}))
|
||||
async fn daemon_info() -> Json<serde_json::Value> {
|
||||
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
|
||||
|
||||
+15
-22
@@ -7,7 +7,6 @@ import type {
|
||||
RecentDirs,
|
||||
Session,
|
||||
Uuid,
|
||||
WebInfo,
|
||||
} from "./types.js";
|
||||
|
||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||
@@ -25,10 +24,6 @@ async function jsonOrError<T>(res: Response): Promise<T> {
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
export async function listDaemons(): Promise<DaemonInfo[]> {
|
||||
return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<Session[]> {
|
||||
return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" }));
|
||||
}
|
||||
@@ -53,31 +48,29 @@ export async function killSession(id: Uuid): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<DirListing> {
|
||||
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<DirListing> {
|
||||
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<WebInfo> {
|
||||
return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" }));
|
||||
export async function getDaemonInfo(): Promise<DaemonInfo> {
|
||||
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<RecentDirs> {
|
||||
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<RecentDirs> {
|
||||
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<GitRoot> {
|
||||
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<GitRoot> {
|
||||
const url = `/api/gitroot?path=${encodeURIComponent(path)}`;
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
|
||||
+10
-16
@@ -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
|
||||
? `<span class="at-name">${esc(info.name)}</span>` +
|
||||
(info.command && info.command !== info.name
|
||||
? `<span class="at-cmd">${esc(info.command)}</span>`
|
||||
: "") +
|
||||
(info.daemon ? `<span class="at-host">${esc(info.daemon)}</span>` : "")
|
||||
: "")
|
||||
: `session <span class="sid">${esc(sessionId.slice(0, 8))}</span>`;
|
||||
const titleAttr = info
|
||||
? `${info.name} · ${info.command} @ ${info.daemon} (${sessionId})`
|
||||
: sessionId;
|
||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
|
||||
@@ -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.
|
||||
|
||||
+39
-94
@@ -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<string, DaemonInfo>,
|
||||
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<string, number>();
|
||||
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 = `<span class="daemon-empty">no daemons connected</span>`;
|
||||
} 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 =
|
||||
`<span class="daemon-name">${escapeHtml(d.daemon_id)}</span>` +
|
||||
`<b class="daemon-count${n === 0 ? " is-empty" : ""}">${n}</b>`;
|
||||
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(
|
||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
||||
return el;
|
||||
}
|
||||
if (noDaemons) {
|
||||
el.classList.add("connecting");
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">◴</div>
|
||||
<div class="empty-title">Waiting for a daemon</div>
|
||||
<p class="empty-hint">No daemons are connected yet. Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
|
||||
return el;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
@@ -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<Session>).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);
|
||||
|
||||
|
||||
Binary file not shown.
+7
-19
@@ -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<string, DaemonInfo>;
|
||||
sessions: Map<Uuid, Session>;
|
||||
}
|
||||
|
||||
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<CatalogListener>();
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-23
@@ -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 {
|
||||
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
||||
<div class="empty-actions"></div>`;
|
||||
noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
|
||||
const waiting = h("div", "empty connecting");
|
||||
waiting.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">◴</div>
|
||||
<div class="empty-title">Waiting for a daemon</div>
|
||||
<p class="empty-hint">Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
|
||||
body.append(welcome, noSessions, waiting);
|
||||
body.append(welcome, noSessions);
|
||||
main.appendChild(el);
|
||||
}
|
||||
|
||||
|
||||
+2
-5
@@ -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;
|
||||
}
|
||||
|
||||
+19
-35
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+18
-135
@@ -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<string, DaemonInfo>,
|
||||
prefill?: SpawnPrefill,
|
||||
sessions?: Map<string, Session>,
|
||||
): void {
|
||||
export function openSpawnModal(prefill?: SpawnPrefill): void {
|
||||
const existing = document.getElementById("modal-host");
|
||||
if (existing) existing.remove();
|
||||
|
||||
@@ -49,10 +35,6 @@ export function openSpawnModal(
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<form class="modal-body" id="spawn-form">
|
||||
<div class="field">
|
||||
<span class="field-label">Host</span>
|
||||
<div class="tile-row" id="host-tiles"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row" id="cmd-tiles"></div>
|
||||
@@ -106,7 +88,6 @@ export function openSpawnModal(
|
||||
|
||||
const form = host.querySelector("#spawn-form") as HTMLFormElement;
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const hostTiles = host.querySelector("#host-tiles") as HTMLElement;
|
||||
const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = host.querySelector("#recents") as HTMLElement;
|
||||
@@ -120,14 +101,13 @@ export function openSpawnModal(
|
||||
const nameInput = host.querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
// ---- form state ------------------------------------------------------
|
||||
let selectedHostId = "";
|
||||
let homeDir = "";
|
||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||
let selectedPresetId = "";
|
||||
let command = ""; // resolved command (preset's, or the raw custom input)
|
||||
let cwd = ""; // chosen working directory
|
||||
let inferredBaseRepo: string | null = null; // from the gitroot probe
|
||||
let probeToken = 0; // guards async gitroot probes against races
|
||||
let showAllHosts = false;
|
||||
// A clone's cwd, applied once the (async) recents load for the initial host
|
||||
// settles — so the fetch can't race past and clobber the seeded directory.
|
||||
let cloneCwd: string | null = prefill?.cwd ?? null;
|
||||
@@ -187,11 +167,12 @@ export function openSpawnModal(
|
||||
}
|
||||
});
|
||||
|
||||
// Presets ride `/api/web/info`; render fallbacks immediately, then refine.
|
||||
// Presets ride `/api/info`; render fallbacks immediately, then refine.
|
||||
renderCommandTiles();
|
||||
applyInitialCommand();
|
||||
getWebInfo()
|
||||
void getDaemonInfo()
|
||||
.then((info) => {
|
||||
homeDir = info.home_dir ?? "";
|
||||
if (info.presets?.length) {
|
||||
presets = info.presets;
|
||||
renderCommandTiles();
|
||||
@@ -260,7 +241,7 @@ export function openSpawnModal(
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
let dirs: RecentDir[] = [];
|
||||
try {
|
||||
dirs = (await recentDirs(selectedHostId)).dirs ?? [];
|
||||
dirs = (await recentDirs()).dirs ?? [];
|
||||
} catch {
|
||||
dirs = [];
|
||||
}
|
||||
@@ -272,13 +253,12 @@ export function openSpawnModal(
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
if (cloneCwd) {
|
||||
// A clone seeds the directory verbatim into the raw input, regardless of
|
||||
// this host's history.
|
||||
// the recorded history.
|
||||
showRawCwd(cloneCwd);
|
||||
cloneCwd = null;
|
||||
} else if (dirs.length === 0) {
|
||||
// No history: default to raw input prefilled with the host's home dir.
|
||||
const home = daemons.get(selectedHostId)?.home_dir ?? "";
|
||||
showRawCwd(home);
|
||||
// No history: default to raw input prefilled with the daemon's home dir.
|
||||
showRawCwd(homeDir);
|
||||
} else {
|
||||
cwdRawWrap.hidden = true;
|
||||
}
|
||||
@@ -292,8 +272,7 @@ export function openSpawnModal(
|
||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||
// and `dataset.cwd` (what's actually submitted).
|
||||
const home = daemons.get(selectedHostId)?.home_dir;
|
||||
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, home));
|
||||
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
|
||||
row.innerHTML = `
|
||||
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
|
||||
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
|
||||
@@ -306,10 +285,7 @@ export function openSpawnModal(
|
||||
row.type = "button";
|
||||
row.className = "recent-row recent-other";
|
||||
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
|
||||
row.addEventListener("click", () => {
|
||||
const home = daemons.get(selectedHostId)?.home_dir ?? "";
|
||||
showRawCwd(cwd || home);
|
||||
});
|
||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -335,7 +311,7 @@ export function openSpawnModal(
|
||||
wtBody.hidden = true;
|
||||
let root: string | undefined;
|
||||
try {
|
||||
root = (await gitRoot(selectedHostId, cwd)).root;
|
||||
root = (await gitRoot(cwd)).root;
|
||||
} catch {
|
||||
root = undefined;
|
||||
}
|
||||
@@ -364,86 +340,7 @@ export function openSpawnModal(
|
||||
}
|
||||
});
|
||||
|
||||
// ---- host tiles ------------------------------------------------------
|
||||
// Order hosts by most-recently-spawned-on first, so the common case is the
|
||||
// pre-selected tile.
|
||||
function orderedHosts(): DaemonInfo[] {
|
||||
const list = [...daemons.values()];
|
||||
const lastUsed = new Map<string, number>();
|
||||
for (const s of sessions?.values() ?? []) {
|
||||
const t = Date.parse(s.created_at);
|
||||
if (Number.isFinite(t)) {
|
||||
lastUsed.set(s.daemon_id, Math.max(lastUsed.get(s.daemon_id) ?? 0, t));
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => (lastUsed.get(b.daemon_id) ?? 0) - (lastUsed.get(a.daemon_id) ?? 0));
|
||||
}
|
||||
|
||||
function selectHost(id: string): void {
|
||||
selectedHostId = id;
|
||||
for (const tile of hostTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
}
|
||||
// Recents + any worktree inference are host-scoped — refresh on change.
|
||||
cwd = "";
|
||||
inferredBaseRepo = null;
|
||||
if (wtEnabled.checked) {
|
||||
wtEnabled.checked = false;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
void loadRecents();
|
||||
}
|
||||
|
||||
function renderHostTiles(): void {
|
||||
hostTiles.replaceChildren();
|
||||
const ordered = orderedHosts();
|
||||
if (ordered.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "picker-loading";
|
||||
empty.textContent = "(no daemons connected)";
|
||||
hostTiles.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const visible = showAllHosts ? ordered : ordered.slice(0, MAX_HOST_TILES);
|
||||
for (const d of visible) {
|
||||
const tile = document.createElement("button");
|
||||
tile.type = "button";
|
||||
tile.className = "tile tile-host";
|
||||
tile.dataset.id = d.daemon_id;
|
||||
tile.innerHTML = `
|
||||
<span class="tile-label">${escapeHtml(d.hostname)}</span>
|
||||
<span class="tile-sub">${escapeHtml(d.daemon_id)}</span>`;
|
||||
tile.addEventListener("click", () => selectHost(d.daemon_id));
|
||||
hostTiles.appendChild(tile);
|
||||
}
|
||||
if (!showAllHosts && ordered.length > MAX_HOST_TILES) {
|
||||
const more = document.createElement("button");
|
||||
more.type = "button";
|
||||
more.className = "tile tile-more";
|
||||
more.innerHTML = `<span class="tile-label">More…</span>`;
|
||||
more.addEventListener("click", () => {
|
||||
showAllHosts = true;
|
||||
renderHostTiles();
|
||||
// Keep the current selection highlighted after re-render.
|
||||
if (selectedHostId) {
|
||||
hostTiles
|
||||
.querySelector(`.tile[data-id="${cssEscape(selectedHostId)}"]`)
|
||||
?.classList.add("is-active");
|
||||
}
|
||||
});
|
||||
hostTiles.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
renderHostTiles();
|
||||
// Pre-select: a clone's host if present & connected, else the most-recent.
|
||||
const ordered = orderedHosts();
|
||||
const initialHost =
|
||||
prefill?.daemon_id && daemons.has(prefill.daemon_id)
|
||||
? prefill.daemon_id
|
||||
: ordered[0]?.daemon_id;
|
||||
if (initialHost) selectHost(initialHost);
|
||||
void loadRecents();
|
||||
|
||||
// ---- clone seed ------------------------------------------------------
|
||||
if (prefill) {
|
||||
@@ -453,9 +350,7 @@ export function openSpawnModal(
|
||||
}
|
||||
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocompletes: DirAutocompleteHandle[] = [
|
||||
attachDirAutocomplete(cwdRaw, () => selectedHostId),
|
||||
];
|
||||
const autocompletes: DirAutocompleteHandle[] = [attachDirAutocomplete(cwdRaw)];
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------
|
||||
const close = () => {
|
||||
@@ -479,10 +374,6 @@ export function openSpawnModal(
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errEl.textContent = "";
|
||||
if (!selectedHostId) {
|
||||
errEl.textContent = "Pick a host.";
|
||||
return;
|
||||
}
|
||||
if (!cwd) {
|
||||
errEl.textContent = "Pick a working directory.";
|
||||
return;
|
||||
@@ -497,7 +388,6 @@ export function openSpawnModal(
|
||||
if (name) metadata.name = name;
|
||||
|
||||
const req: CreateSessionRequest = {
|
||||
daemon_id: selectedHostId,
|
||||
cwd,
|
||||
command,
|
||||
metadata,
|
||||
@@ -543,10 +433,3 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a CSS attribute selector. Uses the native
|
||||
* `CSS.escape` when available, with a conservative fallback. */
|
||||
function cssEscape(s: string): string {
|
||||
const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS;
|
||||
if (cssApi?.escape) return cssApi.escape(s);
|
||||
return s.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
+5
-75
@@ -625,7 +625,7 @@ textarea {
|
||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
|
||||
/* ---------- daemon / status bar ---------- */
|
||||
/* ---------- status bar ---------- */
|
||||
|
||||
.statusbar {
|
||||
display: flex;
|
||||
@@ -645,28 +645,14 @@ textarea {
|
||||
min-width: var(--sp-2);
|
||||
}
|
||||
|
||||
.daemons {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.daemon-chip {
|
||||
.session-tally {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px var(--sp-2) 3px var(--sp-2);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--ink);
|
||||
gap: 7px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.daemon-chip::before {
|
||||
.session-tally::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -675,42 +661,6 @@ textarea {
|
||||
box-shadow: 0 0 5px var(--live-glow);
|
||||
}
|
||||
|
||||
/* Green session count baked into each host chip (replaces the global tally). */
|
||||
.daemon-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: var(--fs-11);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--live);
|
||||
background: color-mix(in srgb, var(--live) 16%, transparent);
|
||||
}
|
||||
|
||||
.daemon-count.is-empty {
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.daemon-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.daemon-empty::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
/* ---------- session tree ---------- */
|
||||
|
||||
.tree {
|
||||
@@ -863,16 +813,6 @@ textarea {
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.leaf-daemon {
|
||||
color: var(--faint);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.leaf-daemon::before {
|
||||
content: "@";
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.leaf-cmd {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
@@ -995,11 +935,6 @@ textarea {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.empty.connecting .empty-glyph {
|
||||
color: var(--warn);
|
||||
animation: pulse 1.6s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
/* ---------- attach view ---------- */
|
||||
|
||||
.attach {
|
||||
@@ -1803,14 +1738,9 @@ textarea {
|
||||
max-width: none;
|
||||
order: -1;
|
||||
}
|
||||
/* daemons drop to their own full-width wrapped line; no side overflow */
|
||||
.statusbar .spacer {
|
||||
display: none;
|
||||
}
|
||||
.daemons {
|
||||
flex: 1 1 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.tree-leaf .leaf-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+13
-30
@@ -12,7 +12,6 @@ export type ActivityState = "Active" | "Idle" | "AwaitingInput";
|
||||
|
||||
export interface Session {
|
||||
id: Uuid;
|
||||
daemon_id: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
env: Record<string, string>;
|
||||
@@ -32,17 +31,7 @@ export interface Session {
|
||||
current_cwd?: string | null;
|
||||
}
|
||||
|
||||
export interface DaemonInfo {
|
||||
daemon_id: string;
|
||||
hostname: string;
|
||||
version: string;
|
||||
daemon_url: string;
|
||||
/** Home dir of the user the daemon runs as; used to prefill the spawn form's
|
||||
* working directory. Empty/absent for older daemons. */
|
||||
home_dir?: string;
|
||||
}
|
||||
|
||||
/** One directory's immediate subdirectories on a daemon's host (NAP-329). */
|
||||
/** One directory's immediate subdirectories (NAP-329). */
|
||||
export interface DirListing {
|
||||
/** Resolved absolute directory that was listed (after ~/relative expansion). */
|
||||
path: string;
|
||||
@@ -60,13 +49,14 @@ export interface SpawnPreset {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** `GET /api/web/info` — the connected daemon's identity plus its spawn presets
|
||||
* (#26). Mirrors the daemon-side `WebInfo`. */
|
||||
export interface WebInfo {
|
||||
daemon_id: string;
|
||||
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
||||
* Mirrors the daemon-side `DaemonInfo`. */
|
||||
export interface DaemonInfo {
|
||||
hostname: string;
|
||||
version: string;
|
||||
daemon_url: string;
|
||||
/** Home dir of the user the daemon runs as; used to prefill the spawn form's
|
||||
* working directory. Empty/absent for older daemons. */
|
||||
home_dir?: string;
|
||||
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
|
||||
presets: SpawnPreset[];
|
||||
}
|
||||
@@ -79,14 +69,14 @@ export interface RecentDir {
|
||||
last_used: IsoTimestamp;
|
||||
}
|
||||
|
||||
/** `GET /api/daemons/:id/recent-dirs` — a host's recent working dirs (#26). */
|
||||
/** `GET /api/recent-dirs` — the daemon's recent working dirs (#26). */
|
||||
export interface RecentDirs {
|
||||
dirs: RecentDir[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/daemons/:id/gitroot?path=` — the enclosing repo toplevel of a path
|
||||
* on a host, or absent when the path isn't inside a repo (#26). */
|
||||
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or absent
|
||||
* when the path isn't inside a repo (#26). */
|
||||
export interface GitRoot {
|
||||
root?: string;
|
||||
}
|
||||
@@ -98,7 +88,6 @@ export interface WorktreeSpec {
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
daemon_id: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -106,16 +95,10 @@ export interface CreateSessionRequest {
|
||||
worktree?: WorktreeSpec;
|
||||
}
|
||||
|
||||
export type RegistryEvent =
|
||||
| {
|
||||
type: "Snapshot";
|
||||
daemons: DaemonInfo[];
|
||||
sessions: Session[];
|
||||
}
|
||||
| { type: "DaemonAdded"; daemon: DaemonInfo }
|
||||
| { type: "DaemonRemoved"; daemon_id: string }
|
||||
export type CatalogEvent =
|
||||
| { type: "Snapshot"; sessions: Session[] }
|
||||
| { type: "SessionAdded"; session: Session }
|
||||
| { type: "SessionRemoved"; daemon_id: string; id: Uuid }
|
||||
| { type: "SessionRemoved"; id: Uuid }
|
||||
| { type: "SessionUpdated"; session: Session }
|
||||
| { type: "Lagged"; skipped: number };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user