diff --git a/web/src/api.ts b/web/src/api.ts index 7ba95b6..d7d7f56 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -3,8 +3,11 @@ import type { CreateSessionRequest, DaemonInfo, DirListing, + GitRoot, + RecentDirs, Session, Uuid, + WebInfo, } from "./types.js"; async function jsonOrError(res: Response): Promise { @@ -58,6 +61,26 @@ export async function listDir(daemonId: string, path: string): Promise { + return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" })); +} + +/** Recently-used working dirs on `daemonId`'s host, newest-first and deduped by + * dir (#26). Drives the spawn form's recents picker. */ +export async function recentDirs(daemonId: string): Promise { + const url = `/api/daemons/${encodeURIComponent(daemonId)}/recent-dirs`; + return jsonOrError(await fetch(url, { credentials: "same-origin" })); +} + +/** Probe the enclosing git repo of `path` on `daemonId`'s host (#26). `root` is + * absent when the path isn't inside a repo. `path` may be `~`/relative/absolute; + * the daemon resolves it against its home dir. */ +export async function gitRoot(daemonId: string, path: string): Promise { + const url = `/api/daemons/${encodeURIComponent(daemonId)}/gitroot?path=${encodeURIComponent(path)}`; + return jsonOrError(await fetch(url, { credentials: "same-origin" })); +} + export function eventsWsUrl(): string { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}/api/events`; diff --git a/web/src/catalog.ts b/web/src/catalog.ts index d6cf1b4..b6df5a4 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -273,7 +273,8 @@ export function renderTopbar(store: CatalogStore): HTMLElement { spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; spawnBtn.addEventListener("click", () => { - openSpawnModal(store.snapshot().daemons); + const snap = store.snapshot(); + openSpawnModal(snap.daemons, undefined, snap.sessions); }); actions.appendChild(spawnBtn); return header; @@ -387,9 +388,10 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { treeHost.replaceChildren(); if (sessions.length === 0) { treeHost.appendChild( - renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => - openSpawnModal(store.snapshot().daemons), - ), + renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => { + const snap = store.snapshot(); + openSpawnModal(snap.daemons, undefined, snap.sessions); + }), ); return; } @@ -406,13 +408,18 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { const cloneHandler = (ev: Event) => { const s = (ev as CustomEvent).detail; const rawName = typeof s.metadata?.name === "string" ? s.metadata.name : ""; - openSpawnModal(store.snapshot().daemons, { - daemon_id: s.daemon_id, - cwd: s.cwd, - command: s.command, - name: rawName, - label: sessionLabel(s) ?? "", - }); + const snap = store.snapshot(); + openSpawnModal( + snap.daemons, + { + daemon_id: s.daemon_id, + cwd: s.cwd, + command: s.command, + name: rawName, + label: sessionLabel(s) ?? "", + }, + snap.sessions, + ); }; document.addEventListener("catalog-clone", cloneHandler); diff --git a/web/src/main.ts b/web/src/main.ts index d2e4012..f4eb6cb 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -70,7 +70,10 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; - spawn.addEventListener("click", () => openSpawnModal(store.snapshot().daemons)); + spawn.addEventListener("click", () => { + const snap = store.snapshot(); + openSpawnModal(snap.daemons, undefined, snap.sessions); + }); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; } diff --git a/web/src/mock.ts b/web/src/mock.ts index 10363c2..a23eafd 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -23,6 +23,57 @@ function sess( }; } +/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full + * tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only; + * delegates every other request to the real `fetch`. */ +function stubSpawnApi(): void { + const real = globalThis.fetch.bind(globalThis); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + 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")) { + return Promise.resolve( + json({ + daemon_id: "thinkpad", + hostname: "thinkpad.lan", + version: "0.1.0", + daemon_url: "wss://thinkpad.lan/daemon", + presets: [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "opencode", label: "opencode", command: "opencode" }, + { id: "zsh", label: "Shell", command: "zsh" }, + { id: "custom", label: "Custom…", command: "" }, + ], + }), + ); + } + if (path.includes("/recent-dirs")) { + return Promise.resolve( + json({ + dirs: [ + { cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" }, + { cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" }, + { cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" }, + ], + truncated: false, + }), + ); + } + if (path.includes("/gitroot")) { + const root = path.includes("maestro") || path.includes("dotfiles") + ? { root: "/home/dev/git/maestro" } + : {}; + return Promise.resolve(json(root)); + } + return real(input, init); + }; +} + 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" }, @@ -57,10 +108,16 @@ export function seedMock(store: CatalogStore): void { sessions, }); - // `?mock&spawn` auto-opens the spawn modal for visual review. + // `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn + // 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]))), + m.openSpawnModal( + new Map(daemons.map((d) => [d.daemon_id, d])), + undefined, + new Map(sessions.map((s) => [s.id, s])), + ), ); } } diff --git a/web/src/names.ts b/web/src/names.ts new file mode 100644 index 0000000..1fcf15a --- /dev/null +++ b/web/src/names.ts @@ -0,0 +1,30 @@ +// Client-side session-handle generator for the spawn form's instant reroll +// (#23/#27). Mirrors the wordlists in `crates/maestro-names/src/lib.rs` verbatim +// — the Rust generator is the source of truth, but the web wants a zero-round- +// trip reroll, so the lists are duplicated here. Keep them in sync. + +/** Musical adjectives — tempo, dynamics, and articulation markings. */ +const ADJECTIVES = [ + "allegro", "andante", "adagio", "presto", "vivace", "largo", "grave", "lento", + "moderato", "crescendo", "staccato", "legato", "forte", "dolce", "agitato", + "cantabile", "sostenuto", "tremolo", "rubato", "marcato", "brillante", + "maestoso", "grazioso", "espressivo", "tranquillo", "animato", "spirito", + "sotto", "vivo", "feroce", +]; + +/** Musical nouns — instruments and forms from the concert hall. */ +const NOUNS = [ + "cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", + "trombone", "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", + "lute", "fugue", "sonata", "octave", "chord", "cadence", "prelude", "rondo", + "nocturne", "scherzo", "refrain", "coda", "motif", "overture", +]; + +function pick(list: T[]): T { + return list[Math.floor(Math.random() * list.length)]; +} + +/** A fresh `adjective-noun` handle, e.g. `allegro-cello`. */ +export function generateName(): string { + return `${pick(ADJECTIVES)}-${pick(NOUNS)}`; +} diff --git a/web/src/spawn.ts b/web/src/spawn.ts index b0490e4..c3f6817 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -1,6 +1,13 @@ -import { createSession } from "./api.js"; -import { attachDirAutocomplete } from "./dirAutocomplete.js"; -import type { CreateSessionRequest, DaemonInfo } from "./types.js"; +import { createSession, getWebInfo, 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"; /** 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. */ @@ -12,9 +19,22 @@ export interface SpawnPrefill { label?: string; } +/** Built-ins used when the daemon's `/api/web/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" }, + { id: "opencode", label: "opencode", command: "opencode" }, + { id: "zsh", label: "Shell", command: "zsh" }, + { id: "custom", label: "Custom…", command: "" }, +]; + +/** How many host tiles to show before collapsing the rest behind "More…". */ +const MAX_HOST_TILES = 4; + export function openSpawnModal( daemons: Map, prefill?: SpawnPrefill, + sessions?: Map, ): void { const existing = document.getElementById("modal-host"); if (existing) existing.remove(); @@ -23,52 +43,61 @@ export function openSpawnModal( host.id = "modal-host"; host.className = "modal-backdrop"; host.innerHTML = ` -