From 818e0cc2dba3cd1ce5b615e6bb7465a7ebe63937 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:15:33 +0100 Subject: [PATCH] feat(maestro): spawn form rerolls from a fetched name batch (#53) --- web/src/api.ts | 8 ++ web/src/names.ts | 29 -------- web/src/projectPicker.test.ts | 7 +- web/src/routes.ts | 4 + web/src/spawnForm.ts | 61 ++++++++++++++-- web/src/spawnFormNames.test.ts | 129 +++++++++++++++++++++++++++++++++ web/src/types.ts | 6 ++ 7 files changed, 207 insertions(+), 37 deletions(-) delete mode 100644 web/src/names.ts create mode 100644 web/src/spawnFormNames.test.ts diff --git a/web/src/api.ts b/web/src/api.ts index 92c3b64..d3867e9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -5,6 +5,7 @@ import type { DaemonInfo, DirListing, GitRoot, + NameBatch, Projects, RecentDirs, Session, @@ -81,4 +82,11 @@ export async function gitRoot(path: string): Promise { return jsonOrError(await fetch(url, { credentials: "same-origin" })); } +/** A batch of fresh session handles for the spawn form's instant reroll (#53). + * Rerolls consume the batch locally; only exhaustion goes back to the daemon. */ +export async function fetchNames(count: number): Promise { + const url = `${routes.NAMES}?${routes.COUNT_QUERY}=${encodeURIComponent(count)}`; + return jsonOrError(await fetch(url, { credentials: "same-origin" })); +} + export { eventsWsUrl } from "./routes.js"; diff --git a/web/src/names.ts b/web/src/names.ts deleted file mode 100644 index 9de5d70..0000000 --- a/web/src/names.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Client-side session-handle generator for the spawn form's instant reroll -// (#23/#27). The spawn form rerolls without a round trip, so the wordlists -// live here; nothing server-side generates handles. - -/** 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/projectPicker.test.ts b/web/src/projectPicker.test.ts index f3e2b51..ba6b821 100644 --- a/web/src/projectPicker.test.ts +++ b/web/src/projectPicker.test.ts @@ -15,7 +15,8 @@ function project(overrides: Partial): Project { }; } -/** Two fetches gate the form's initial load: /api/info then /api/projects. */ +/** Three fetches gate the form's initial load: /api/info, /api/projects, and + * the /api/names batch (#53). */ function stubFetch(info: unknown, projects: Project[]): void { vi.stubGlobal( "fetch", @@ -25,7 +26,9 @@ function stubFetch(info: unknown, projects: Project[]): void { ? info : url.includes("/api/projects") ? projects - : undefined; + : url.includes("/api/names") + ? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] } + : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), ); diff --git a/web/src/routes.ts b/web/src/routes.ts index 1263611..c76bf8a 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -11,11 +11,15 @@ export const DIRS = "/api/dirs"; export const RECENT_DIRS = "/api/recent-dirs"; export const PROJECTS = "/api/projects"; export const GITROOT = "/api/gitroot"; +export const NAMES = "/api/names"; export const EVENTS = "/api/events"; /** Name of the query parameter `DIRS` and `GITROOT` both take. */ export const PATH_QUERY = "path"; +/** Name of the query parameter `NAMES` takes. */ +export const COUNT_QUERY = "count"; + export function session(id: Uuid): string { return `${SESSIONS}/${encodeURIComponent(id)}`; } diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 916ca18..3351949 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -1,8 +1,7 @@ import { escapeHtml } from "./html.js"; -import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js"; +import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js"; import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; -import { generateName } from "./names.js"; import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import { abbreviateCwd, abbreviateFolderName } from "./types.js"; @@ -197,8 +196,46 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const syncBranchToName = () => { if (!branchEdited) branchInput.value = nameInput.value; }; - nameInput.value = prefill.name ?? generateName(); + // Handles come from `GET /api/names` in batches (#53): the first one seeds + // the field, rerolls consume the rest locally so they stay instant, and a + // new batch is fetched when (and only when) the queue runs dry. One refill + // at a time keeps rapid rerolls from storming the daemon. + const NAME_BATCH_SIZE = 20; + let nameQueue: string[] = []; + let nameFetch: Promise | null = null; + // Guards against a late batch clobbering something the operator already + // typed or rerolled. + let nameTouched = Boolean(prefill.name); + const refillNames = (): Promise => { + if (!nameFetch) { + nameFetch = fetchNames(NAME_BATCH_SIZE) + .then((batch) => { + if (!destroyed) nameQueue.push(...batch.names); + }) + .catch(() => { + /* reroll just stays on the current name */ + }) + .finally(() => { + nameFetch = null; + }); + } + return nameFetch; + }; + + nameInput.value = prefill.name ?? ""; + if (!nameTouched) { + void refillNames().then(() => { + if (destroyed || nameTouched || nameInput.value) return; + const next = nameQueue.shift(); + if (next !== undefined) { + nameInput.value = next; + syncBranchToName(); + emit(); + } + }); + } nameInput.addEventListener("input", () => { + nameTouched = true; syncBranchToName(); emit(); }); @@ -207,9 +244,21 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { emit(); }); (form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => { - nameInput.value = generateName(); - syncBranchToName(); - emit(); + nameTouched = true; + const apply = () => { + if (destroyed) return; + const next = nameQueue.shift(); + if (next === undefined) return; + nameInput.value = next; + syncBranchToName(); + emit(); + }; + // Only hit the daemon when the local batch is exhausted. + if (nameQueue.length === 0) { + void refillNames().then(apply); + } else { + apply(); + } }); syncBranchToName(); // seed the branch from the initial name diff --git a/web/src/spawnFormNames.test.ts b/web/src/spawnFormNames.test.ts new file mode 100644 index 0000000..50aab84 --- /dev/null +++ b/web/src/spawnFormNames.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; + +const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"]; +const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"]; + +/** Stub the three fetches the form's initial load makes. Records every + * /api/names URL so tests can assert how many batches were fetched. */ +function stubFetch(batches: string[][]): string[] { + let batch = 0; + const nameUrls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/names")) { + nameUrls.push(url); + const names = batches[Math.min(batch, batches.length - 1)]; + batch += 1; + return new Response(JSON.stringify({ names }), { status: 200 }); + } + const body = url.includes("/api/info") + ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } + : []; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); + return nameUrls; +} + +describe("spawnForm name batch (#53)", () => { + let handle: SpawnFormHandle | null = null; + let nameUrls: string[]; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function mount(seed?: Parameters[0]["seed"]) { + nameUrls = stubFetch([BATCH_A, BATCH_B]); + handle = createSpawnForm({ seed, onSuccess() {} }); + document.body.appendChild(handle.el); + for (let i = 0; i < 40; i++) await Promise.resolve(); + return handle.el; + } + + const nameOf = (form: HTMLFormElement) => + form.querySelector("input[name=name]")!.value; + + function reroll(form: HTMLFormElement): void { + form.querySelector(".reroll")!.click(); + } + + async function drain(): Promise { + for (let i = 0; i < 40; i++) await Promise.resolve(); + } + + it("seeds its name from a fetched batch when no seed provides one", async () => { + const form = await mount(); + expect(BATCH_A).toContain(nameOf(form)); + expect(nameUrls).toHaveLength(1); + expect(nameUrls[0]).toContain("/api/names"); + expect(nameUrls[0]).toContain("count="); + }); + + it("keeps a seeded name and defers fetching until the first reroll", async () => { + const form = await mount({ name: "my-session" }); + expect(nameOf(form)).toBe("my-session"); + expect(nameUrls ?? []).toHaveLength(0); + + reroll(form); + await drain(); + expect(nameOf(form)).not.toBe("my-session"); + expect(nameUrls).toHaveLength(1); + }); + + it("rerolls locally from the batch with no extra round trips", async () => { + const form = await mount(); + expect(nameUrls).toHaveLength(1); + const seen = new Set([nameOf(form)]); + + for (let i = 0; i < BATCH_A.length; i++) { + reroll(form); + seen.add(nameOf(form)); + } + await drain(); + + // Every value came from the one fetched batch. + expect(seen.size).toBeGreaterThanOrEqual(2); + for (const value of seen) { + expect([...BATCH_A, ...BATCH_B]).toContain(value); + } + expect(nameUrls).toHaveLength(2); // refetch fired when the batch ran out + expect(nameUrls[1]).toContain("/api/names"); + }); + + it("refetches only once even if rerolls outpace the network", async () => { + const form = await mount(); + // Drain the batch without awaiting between rerolls. + for (let i = 0; i < BATCH_A.length + 1; i++) reroll(form); + await drain(); + // One refill in flight covers the whole run — no fetch storm. + expect(nameUrls.length).toBeLessThanOrEqual(2); + }); + + it("does not clobber an operator-typed name when the batch arrives late", async () => { + nameUrls = stubFetch([BATCH_A]); + handle = createSpawnForm({ onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + + // The operator types before the fetch resolves. + const input = form.querySelector("input[name=name]")!; + input.value = "hand-typed"; + input.dispatchEvent(new Event("input")); + for (let i = 0; i < 40; i++) await Promise.resolve(); + + expect(nameOf(form)).toBe("hand-typed"); + }); +}); diff --git a/web/src/types.ts b/web/src/types.ts index 4a56bde..844a02a 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -83,6 +83,12 @@ export interface GitRoot { root?: string; } +/** `GET /api/names?count=N` — a batch of fresh session handles (#53). Mirrors + * `maestro_protocol::NameBatch`. */ +export interface NameBatch { + names: string[]; +} + /** One discovered git repository (or linked worktree) for `GET /api/projects` * (#54). Mirrors `maestro_protocol::Project`. */ export interface Project {