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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user