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>
This commit is contained in:
+47
-13
@@ -2,7 +2,7 @@ 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 { sessionLabel, sessionName } from "./types.js";
|
||||
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
|
||||
@@ -29,21 +29,26 @@ function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||
return an.localeCompare(bn);
|
||||
}
|
||||
|
||||
function buildTree(sessions: Session[], filter: string): TreeNode {
|
||||
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)} ${sessionLabel(s) ?? ""}`.toLowerCase();
|
||||
const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase();
|
||||
if (!haystack.includes(lc)) continue;
|
||||
}
|
||||
const label = sessionLabel(s);
|
||||
// Host is the first level of nesting; the label path nests beneath it, so
|
||||
// the daemon no longer needs to ride along on each leaf.
|
||||
const labelParts = label ? label.split("/").filter(Boolean) : [];
|
||||
const parts = [s.daemon_id, ...labelParts];
|
||||
// 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;
|
||||
@@ -64,9 +69,39 @@ function buildTree(sessions: Session[], filter: string): TreeNode {
|
||||
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");
|
||||
@@ -406,7 +441,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
const refresh = () => {
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, filter);
|
||||
const tree = buildTree(sessions, state.daemons, filter);
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
@@ -427,8 +462,8 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir,
|
||||
// command (matched back to a preset by the form), and label — but with a
|
||||
// freshly generated name rather than the original's, since it's a new session.
|
||||
// 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();
|
||||
@@ -438,7 +473,6 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
daemon_id: s.daemon_id,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
label: sessionLabel(s) ?? "",
|
||||
},
|
||||
snap.sessions,
|
||||
);
|
||||
|
||||
+7
-8
@@ -213,25 +213,24 @@ export function renderKit(): HTMLElement {
|
||||
main.appendChild(el);
|
||||
}
|
||||
|
||||
// --- session rows, nested by project label ---
|
||||
// --- session rows, nested by working directory ---
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Session rows — nested by host, then project",
|
||||
"Host is the first level of nesting; the project label nests beneath it, so the daemon no longer rides on each row. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
|
||||
"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.",
|
||||
);
|
||||
const list = h("div", "kit-tree");
|
||||
list.append(
|
||||
folderHeader("thinkpad", 3, 0),
|
||||
folderHeader("maestro", 3, 1),
|
||||
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("server", 1, 2),
|
||||
folderHeader("crates/registry", 1, 2),
|
||||
leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }),
|
||||
folderHeader("tower", 2, 0),
|
||||
folderHeader("work", 1, 1),
|
||||
folderHeader("acme", 1, 2),
|
||||
leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 3 }),
|
||||
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 }),
|
||||
|
||||
+26
-21
@@ -13,22 +13,25 @@ function sess(
|
||||
daemon: string,
|
||||
command: string,
|
||||
status: "Running" | "Dead",
|
||||
meta: Record<string, unknown>,
|
||||
cwd: string,
|
||||
name?: string,
|
||||
): Session {
|
||||
const seed = parseInt(id.slice(-1), 16) || 0;
|
||||
return {
|
||||
id,
|
||||
daemon_id: daemon,
|
||||
cwd: "/home/dev",
|
||||
cwd,
|
||||
command,
|
||||
env: {},
|
||||
metadata: meta,
|
||||
metadata: name ? { name } : {},
|
||||
status,
|
||||
created_at: "2026-06-15T12:00:00Z",
|
||||
// Inferred fields (#18) so the HUD has real data to render in review.
|
||||
foreground: command.split(" ")[0],
|
||||
activity: ACTIVITIES[seed % ACTIVITIES.length],
|
||||
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
|
||||
// The session tree nests by current_cwd; default it to the spawn dir.
|
||||
current_cwd: cwd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,24 +93,26 @@ export function seedMock(store: CatalogStore): void {
|
||||
{ daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" },
|
||||
];
|
||||
const sessions: Session[] = [
|
||||
// maestro/web/* — a project with nested sub-labels (two levels deep)
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", { name: "redesign", label: "maestro/web/frontend" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", { name: "dev-server", label: "maestro/web/frontend" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", { name: "scratch", label: "maestro/web/frontend" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", { name: "e2e-fixes", label: "maestro/web/testing" }),
|
||||
// maestro/server/* — sibling project, also nested
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", { name: "registry-broker", label: "maestro/server/registry" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", { name: "test-loop", label: "maestro/server/registry" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", { name: "daemon", label: "maestro/server/daemon" }),
|
||||
// work/* — a second top-level group with its own client nesting
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", { name: "billing-api", label: "work/acme/api" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", { name: "dashboard", label: "work/acme/web" }),
|
||||
// personal/* — flat-ish group
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", { name: "dotfiles", label: "personal" }),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", { name: "monitor", label: "personal" }),
|
||||
// ungrouped — no label, falls to the tree root (name from command)
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", {}),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", {}),
|
||||
// ~/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"),
|
||||
// ~/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"),
|
||||
// ~/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"),
|
||||
// ~/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"),
|
||||
// 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"),
|
||||
];
|
||||
// The store applies RegistryEvents internally; feed a Snapshot the same way
|
||||
// the WS would. `apply` is private, so reach it through an index cast.
|
||||
|
||||
+1
-9
@@ -10,13 +10,12 @@ import type {
|
||||
} from "./types.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
|
||||
* the same daemon, cwd, command, name, and label as an existing session. */
|
||||
* the same daemon, cwd, command, and name as an existing session. */
|
||||
export interface SpawnPrefill {
|
||||
daemon_id?: string;
|
||||
cwd?: string;
|
||||
command?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Built-ins used when the daemon's `/api/web/info` presets can't be fetched
|
||||
@@ -74,10 +73,6 @@ export function openSpawnModal(
|
||||
<button type="button" class="btn btn-icon reroll" id="reroll" aria-label="Generate a new name" title="New name">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
Label (optional)
|
||||
<input name="label" type="text" placeholder="personal/maestro" autocomplete="off" />
|
||||
</label>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<label class="row-inline">
|
||||
@@ -447,7 +442,6 @@ export function openSpawnModal(
|
||||
// ---- clone seed ------------------------------------------------------
|
||||
if (prefill) {
|
||||
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
|
||||
(host.querySelector("input[name=label]") as HTMLInputElement).value = prefill.label ?? "";
|
||||
// The cwd itself is applied by loadRecents once the initial host's recents
|
||||
// settle (see `cloneCwd`), so a slow fetch can't clobber it.
|
||||
}
|
||||
@@ -494,9 +488,7 @@ export function openSpawnModal(
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
const name = nameInput.value.trim();
|
||||
const label = (host.querySelector("input[name=label]") as HTMLInputElement).value.trim();
|
||||
if (name) metadata.name = name;
|
||||
if (label) metadata.label = label;
|
||||
|
||||
const req: CreateSessionRequest = {
|
||||
daemon_id: selectedHostId,
|
||||
|
||||
+18
-4
@@ -26,6 +26,10 @@ export interface Session {
|
||||
activity?: ActivityState;
|
||||
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
||||
last_activity_at?: IsoTimestamp;
|
||||
/** The pane's *current* working directory, as the daemon's activity watcher
|
||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||
* Drives the directory-based session tree. Absent until first observed. */
|
||||
current_cwd?: string | null;
|
||||
}
|
||||
|
||||
export interface DaemonInfo {
|
||||
@@ -132,8 +136,18 @@ export function sessionName(s: Session): string {
|
||||
return s.command;
|
||||
}
|
||||
|
||||
export function sessionLabel(s: Session): string | undefined {
|
||||
const meta = s.metadata as Record<string, unknown>;
|
||||
const label = meta?.label;
|
||||
return typeof label === "string" ? label : undefined;
|
||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
|
||||
export function sessionCwd(s: Session): string | undefined {
|
||||
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
||||
return s.cwd || undefined;
|
||||
}
|
||||
|
||||
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
||||
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
||||
export function abbreviateCwd(cwd: string, home?: string): string {
|
||||
if (home && (cwd === home || cwd.startsWith(`${home}/`))) {
|
||||
return `~${cwd.slice(home.length)}`;
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user