diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index bb9f461..b81e5f7 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -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", diff --git a/web/src/catalog.ts b/web/src/catalog.ts index bc0bd0e..73e3d5e 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -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)} - ${escapeHtml(name)} + ${escapeHtml(name)} diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts index 8766b67..17da120 100644 --- a/web/src/projectPicker.test.ts +++ b/web/src/projectPicker.test.ts @@ -162,13 +162,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => const box = form.querySelector(".project-box")!; box.click(); - const values = form.querySelector("input[name=name]")!.value; + const name = form.querySelector("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); diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 1d81a24..806245d 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -142,8 +142,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
Name
- - +
@@ -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 | 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; - }); + 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 diff --git a/web/src/spawnFormNames.test.ts b/web/src/spawnFormNames.test.ts index ebe69db..5c9b087 100644 --- a/web/src/spawnFormNames.test.ts +++ b/web/src/spawnFormNames.test.ts @@ -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[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("input[name=name]")!.value; + const branchOf = (form: HTMLFormElement) => + form.querySelector("input[name=worktree_branch]")!.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 () => { + 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("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("input[name=name]")!; - input.value = "hand-typed"; - input.dispatchEvent(new Event("input")); + const branch = form.querySelector("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"); }); }); diff --git a/web/src/styles.css b/web/src/styles.css index b66cc2e..f9aafe3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 { diff --git a/web/src/types.ts b/web/src/types.ts index 8c20eeb..53e6a2c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; + 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;