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:
+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, "\\$&");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user