This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/catalog.ts
T
naps62-yoloandClaude Opus 4.8 458c81094b feat(maestro): nest sessions by live working directory, drop labels
Replace the freeform `metadata.label` session grouping with nesting derived
from each session's working directory. The daemon's activity watcher already
polls each tmux pane every tick, so it now also captures
`#{pane_current_path}` and surfaces it as a new `Session.current_cwd`
(serde-default, back-compat); `cwd` stays the spawn dir for worktree
inference, spawn-similar, and recents.

Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home
dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as
one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms
(web + TUI) and `--label` flag are removed.

The pane format stays space-delimited (parser splits the first three tokens
and keeps the remainder as the path) so a cwd containing spaces survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:11:23 +00:00

497 lines
17 KiB
TypeScript

import { 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 { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
interface TreeFolder {
kind: "folder";
name: string;
path: string;
children: TreeNode[];
}
interface TreeLeaf {
kind: "leaf";
session: Session;
}
type TreeNode = TreeFolder | TreeLeaf;
const collapsed = new Set<string>();
function compareNodes(a: TreeNode, b: TreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const an = a.kind === "folder" ? a.name : sessionName(a.session);
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
return an.localeCompare(bn);
}
function buildTree(
sessions: Session[],
daemons: Map<string, DaemonInfo>,
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();
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];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
let next = cursor.children.find(
(n): n is TreeFolder => n.kind === "folder" && n.name === part,
);
if (!next) {
next = { kind: "folder", name: part, path, children: [] };
cursor.children.push(next);
}
cursor = next;
}
cursor.children.push({ kind: "leaf", session: s });
}
const sortRec = (n: TreeFolder) => {
n.children.sort(compareNodes);
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,
);
}
}
return root;
}
/** Fold a folder with a single folder-child into one node (`a` + `b` → `a/b`),
* repeating down the chain, then recurse into the surviving branches. The merged
* node keeps the deepest path so collapse/expand state stays stable as it grows. */
function mergeLinear(folder: TreeFolder): TreeFolder {
let cur = folder;
while (cur.children.length === 1 && cur.children[0].kind === "folder") {
const child = cur.children[0];
cur = {
kind: "folder",
name: `${cur.name}/${child.name}`,
path: child.path,
children: child.children,
};
}
cur.children = cur.children.map((c) =>
c.kind === "folder" ? mergeLinear(c) : c,
);
return cur;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
wrap.className = "tree-folder";
if (node.name) {
const header = document.createElement("button");
header.type = "button";
header.className = "tree-folder-header";
header.style.paddingLeft = `${depth * 16}px`;
const isCollapsed = collapsed.has(node.path);
header.setAttribute("aria-expanded", String(!isCollapsed));
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
// glyph and the rotation never fight each other.
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name">${escapeHtml(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
header.addEventListener("click", () => {
if (collapsed.has(node.path)) collapsed.delete(node.path);
else collapsed.add(node.path);
// re-render handled by caller observing the store; trigger a refresh:
document.dispatchEvent(new CustomEvent("catalog-rerender"));
});
wrap.appendChild(header);
if (isCollapsed) return wrap;
}
for (const child of node.children) {
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
}
return wrap;
}
const s = node.session;
const row = document.createElement("a");
row.className = "tree-leaf";
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
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
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
row.innerHTML = `
<span class="leaf-name">${escapeHtml(name)}</span>
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
${running ? "" : `<span class="leaf-badge">exited</span>`}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
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;
killBtn.addEventListener("click", async (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
try {
await killSession(s.id);
} catch (e) {
alert(`Failed to kill: ${(e as Error).message}`);
}
});
return row;
}
function countLeaves(node: TreeNode): number {
if (node.kind === "leaf") return 1;
return node.children.reduce((n, c) => n + countLeaves(c), 0);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
const total = sessions.length;
const live = sessions.filter((s) => s.status === "Running").length;
const tally = document.createElement("div");
tally.className = "session-tally";
tally.innerHTML =
total === 0
? `<span>No sessions</span>`
: `<span><b>${total}</b> session${total === 1 ? "" : "s"}</span>` +
`<span class="tally-live"><b>${live}</b> live</span>`;
wrap.appendChild(tally);
const spacer = document.createElement("div");
spacer.className = "spacer";
wrap.appendChild(spacer);
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 chip = document.createElement("span");
chip.className = "daemon-chip";
chip.title = `${d.hostname} · ${d.daemon_url}`;
chip.textContent = d.daemon_id;
daemonsWrap.appendChild(chip);
}
}
wrap.appendChild(daemonsWrap);
return wrap;
}
function renderEmpty(
noDaemons: boolean,
filtering: boolean,
onSpawn: () => void,
): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
if (filtering) {
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌕</div>
<div class="empty-title">No matches</div>
<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>
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
const spawn = document.createElement("button");
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", onSpawn);
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
/**
* The global app header (full width, above the sidebar/content split): brand +
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
* stay put and reachable regardless of which pane is showing.
*/
/** Navbar toggle for the debug HUD (#20), next to the design-kit shortcut.
* Reflects the current state and stays in sync with the Ctrl+Shift+D keybind. */
function createDebugToggle(): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-ghost debug-toggle";
btn.textContent = "debug";
btn.title = "Toggle the debug HUD (Ctrl+Shift+D)";
const sync = () => {
const on = isHudEnabled();
btn.setAttribute("aria-pressed", String(on));
btn.classList.toggle("is-active", on);
};
btn.addEventListener("click", () => toggleHud());
window.addEventListener(HUD_TOGGLE_EVENT, sync);
sync();
return btn;
}
export function renderTopbar(store: CatalogStore): HTMLElement {
const header = document.createElement("header");
header.className = "app-header";
header.innerHTML = `
<a class="brand" href="#/" aria-label="maestro — clear selection">
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
</a>
<div class="app-status"></div>
<div class="app-actions"></div>
`;
// Session tally + daemon chips live 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()]),
);
};
store.subscribe(renderStatus);
renderStatus();
const actions = header.querySelector(".app-actions") as HTMLElement;
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
actions.appendChild(createDebugToggle());
actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
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);
});
actions.appendChild(spawnBtn);
return header;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
export interface SidebarHandle {
/** The `<aside>` element to mount into the shell. */
el: HTMLElement;
/** Highlight the row for `id` (or clear it with `null`) as the open session. */
setActive(id: string | null): void;
/** Tear down the store subscription + listeners. */
dispose(): void;
}
/**
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
* live daemon/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.
*/
export function renderSidebar(store: CatalogStore): SidebarHandle {
const el = document.createElement("aside");
el.className = "sidebar";
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
<input
type="search"
class="search"
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
`;
el.appendChild(searchHeader);
const treeHost = document.createElement("div");
treeHost.className = "tree";
el.appendChild(treeHost);
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
// width as a :root CSS var the shell grid reads; clamped to a sane range.
const SIDEBAR_W_KEY = "maestro-sidebar-w";
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
const handle = document.createElement("div");
handle.className = "sidebar-resize";
handle.setAttribute("aria-hidden", "true");
el.appendChild(handle);
let dragStartX = 0;
let dragStartW = 0;
let dragging = false;
handle.addEventListener("pointerdown", (e) => {
dragging = true;
dragStartX = e.clientX;
dragStartW = el.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
document.body.style.userSelect = "none";
e.preventDefault();
});
handle.addEventListener("pointermove", (e) => {
if (!dragging) return;
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
});
const endDrag = (e: PointerEvent) => {
if (!dragging) return;
dragging = false;
try {
handle.releasePointerCapture(e.pointerId);
} catch {
// pointer may already be released
}
document.body.style.userSelect = "";
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
};
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
const search = searchHeader.querySelector(".search") as HTMLInputElement;
let filter = "";
search.addEventListener("input", () => {
filter = search.value;
refresh();
});
let activeId: string | null = null;
const applyActive = () => {
for (const n of treeHost.querySelectorAll(".tree-leaf.is-active")) {
n.classList.remove("is-active");
}
if (activeId) {
const row = treeHost.querySelector(
`.tree-leaf[data-session-id="${CSS.escape(activeId)}"]`,
);
row?.classList.add("is-active");
}
};
const refresh = () => {
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, state.daemons, 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);
}),
);
return;
}
if (tree.kind === "folder") {
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyActive();
};
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
// 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,
);
};
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
const unsubscribe = store.subscribe(refresh);
return {
el,
setActive(id: string | null) {
activeId = id;
applyActive();
},
dispose() {
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
unsubscribe();
},
};
}