feat(maestro): web spawn form stops naming sessions

The name field starts empty and the reroll is gone; the worktree branch keeps
a default by borrowing one handle from /api/names. A card whose name the
daemon generated renders dimmed until a first prompt settles it.

Refs #131
This commit is contained in:
Miguel Palhas
2026-09-02 18:00:03 +01:00
parent e7b323b412
commit 6173c86694
7 changed files with 120 additions and 119 deletions
+28
View File
@@ -246,6 +246,34 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("dims a name the daemon generated, until it settles (#131)", () => {
const generated = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
metadata: { name: "amber-falcon", name_source: "Generated" },
});
const haiku = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
metadata: { name: "sidebar-rail", name_source: "Haiku" },
});
const chosen = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
metadata: { name: "my-session", name_source: "User" },
});
// Named before `name_source` existed: settled, never dimmed.
const legacy = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
metadata: { name: "older-session" },
});
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
const provisional = (s: Session) =>
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
expect(provisional(generated)).toBe(true);
expect(provisional(haiku)).toBe(false);
expect(provisional(chosen)).toBe(false);
expect(provisional(legacy)).toBe(false);
});
it("renders the command as its tool icon when the tool is known", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
isProvisionalName,
sessionName,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
@@ -172,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
row.innerHTML = `
${activityRailMarkup(slug, stateWord)}
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</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>
+4 -2
View File
@@ -162,13 +162,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const name = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
// where it was, whatever the project was last run with.
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// Picking a project is not naming a session: the field stays empty and
// the daemon names it (#131).
expect(name).toBe("");
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
+28 -60
View File
@@ -142,8 +142,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off" />
<button type="button" class="btn btn-icon reroll" aria-label="Generate a new name" title="New name">↻</button>
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<fieldset class="worktree">
@@ -257,57 +257,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
};
// ---- name (also seeds the worktree branch) ---------------------------
// The worktree branch defaults to the session name — including a clone's
// prefilled name — and tracks it on reroll/edit until the user types their
// own branch. Only in "new" mode: an existing-branch pick is not a name.
// The field starts empty (#131): a session the operator does not name is
// named by the daemon — a provisional handle at spawn, replaced by one
// derived from the first prompt. Typing here opts out of all of that, and
// still seeds the worktree branch as it always did.
// True only once the operator types their own branch. It cannot be inferred
// from the seed: in "new" mode `emit()` publishes the auto-synced session
// name, so seeding from it froze the branch after any form rebuild
// (#67 review). `worktreeBranchEdited` is carried explicitly instead.
let branchEdited = Boolean(prefill.worktreeBranchEdited);
// A cleared name leaves the branch on its last value rather than blanking
// it: the branch still has to be something, and the session name no longer
// has to be anything.
const syncBranchToName = () => {
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
};
// 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<void> | null = null;
// Guards against a late batch clobbering something the operator already
// typed or rerolled.
let nameTouched = Boolean(prefill.name);
const refillNames = (): Promise<void> => {
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;
});
if (!branchEdited && branchMode === "new" && nameInput.value) {
branchInput.value = nameInput.value;
}
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();
});
@@ -315,24 +284,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
branchEdited = true;
emit();
});
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
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
syncBranchToName(); // seed the branch from a clone's prefilled name
// The branch field still needs a default, and the name field no longer
// provides one, so an unnamed spawn borrows one handle from `GET /api/names`
// (#53) for the branch alone.
if (!branchInput.value && !branchEdited && branchMode === "new") {
void fetchNames(1)
.then((batch) => {
const handle = batch.names[0];
if (destroyed || !handle) return;
if (branchInput.value || branchEdited || branchMode !== "new") return;
branchInput.value = handle;
emit();
})
.catch(() => {
/* the operator types their own branch */
});
}
// ---- command tiles ---------------------------------------------------
// `focus` is false for the two initial applications (construction, and again
+29 -56
View File
@@ -4,10 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["cello", "fugue", "lyre"];
const BATCH_B = ["oboe", "tuba", "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. */
/** Stub the fetches the form's initial load makes. Records every /api/names
* URL so tests can assert how many handles were asked for. */
function stubFetch(batches: string[][]): string[] {
let batch = 0;
const nameUrls: string[] = [];
@@ -30,7 +29,7 @@ function stubFetch(batches: string[][]): string[] {
return nameUrls;
}
describe("spawnForm name batch (#53)", () => {
describe("spawnForm naming (#131)", () => {
let handle: SpawnFormHandle | null = null;
let nameUrls: string[];
@@ -46,7 +45,7 @@ describe("spawnForm name batch (#53)", () => {
});
async function mount(seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
nameUrls = stubFetch([BATCH_A, BATCH_B]);
nameUrls = stubFetch([BATCH_A]);
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
@@ -55,75 +54,49 @@ describe("spawnForm name batch (#53)", () => {
const nameOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const branchOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!.value;
function reroll(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".reroll")!.click();
}
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
it("seeds its name from a fetched batch when no seed provides one", async () => {
it("leaves the name field empty so the daemon names the session", 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=");
expect(nameOf(form)).toBe("");
expect(form.querySelector(".reroll")).toBeNull();
});
it("keeps a seeded name and defers fetching until the first reroll", async () => {
it("still seeds the worktree branch with one generated handle", async () => {
const form = await mount();
expect(BATCH_A).toContain(branchOf(form));
expect(nameUrls).toHaveLength(1);
expect(nameUrls[0]).toContain("count=1");
});
it("keeps a name a clone prefilled, and seeds the branch from it", 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);
expect(branchOf(form)).toBe("my-session");
});
it("rerolls locally from the batch with no extra round trips", async () => {
it("seeds the branch from a typed name, overriding the fetched handle", async () => {
const form = await mount();
expect(nameUrls).toHaveLength(1);
const seen = new Set([nameOf(form)]);
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
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");
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("hand-typed");
});
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 () => {
it("never overwrites a branch the operator typed", 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<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
const branch = form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!;
branch.value = "feat/mine";
branch.dispatchEvent(new Event("input"));
for (let i = 0; i < 40; i++) await Promise.resolve();
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("feat/mine");
});
});
+8
View File
@@ -1200,6 +1200,14 @@ textarea {
white-space: nowrap;
}
/* #131: a name the daemon generated at spawn is provisional — the first prompt
may replace it — so it reads as placeholder text rather than a chosen name.
A session that never prompts simply keeps it, dimmed; that is accurate. */
.leaf-name.is-provisional {
color: var(--muted);
font-weight: 400;
}
/* Inline rename field (#125): sits exactly where the name it replaces did, so
the card does not jump when the edit starts. */
input.leaf-rename {
+21
View File
@@ -292,6 +292,27 @@ export function customSessionName(s: Session): string | undefined {
return undefined;
}
/** Where a session's name came from (#131). Mirrors
* `maestro_protocol::NameSource`. */
export type NameSource = "User" | "Generated" | "Haiku";
/** Read `metadata.name_source`, leniently: anything unrecognised — including
* a session named before the field existed — is a settled `User` name, so an
* existing name is never dimmed or overwritten. */
export function nameSource(s: Session): NameSource {
const meta = s.metadata as Record<string, unknown>;
const source = meta?.name_source;
if (source === "Generated" || source === "Haiku") return source;
return "User";
}
/** Whether line 1 renders the name as provisional (#131): a handle the daemon
* generated at spawn and may still replace with one derived from the first
* prompt. See docs/SIDEBAR.md. */
export function isProvisionalName(s: Session): boolean {
return customSessionName(s) !== undefined && nameSource(s) === "Generated";
}
/** Longest session name the daemon stores (#125), in characters. Mirrors
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
export const MAX_SESSION_NAME_LEN = 64;