From 8e1f26ac8db4121726daecf89e1df24ea0c9ec8a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 31 Aug 2026 14:39:29 +0100 Subject: [PATCH] fix(maestro): spawn similar preselects the cloned project Spawn similar dropped the session's cwd into the free-text directory hatch, so the project picker showed nothing selected. For a worktree session that cwd was the daemon-made checkout, and the worktree toggle defaulted on, so the clone asked for a worktree of a worktree. It now seeds the picker: the cloned project's box selected, the same worktree decision, base repo plus '+ new' when the source had a worktree. A seeded path that matches no project falls back to the raw input showing that path instead of an empty picker. --- web/src/main.ts | 7 +++--- web/src/spawnForm.ts | 5 +++- web/src/spawnSimilar.test.ts | 45 ++++++++++++++++++++++++++++++++++++ web/src/spawnSimilar.ts | 30 ++++++++++++++++++++++++ web/src/worktreeStep.test.ts | 31 +++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 web/src/spawnSimilar.test.ts create mode 100644 web/src/spawnSimilar.ts diff --git a/web/src/main.ts b/web/src/main.ts index 11130bd..bd002ee 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -15,6 +15,7 @@ import { resolveFastForward, } from "./fastForward.js"; import { FALLBACK_PRESETS } from "./spawnForm.js"; +import { spawnSimilarSeed } from "./spawnSimilar.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; @@ -265,8 +266,8 @@ function showMultiView(): void { } // Spawn affordances (#62): the header button, the sidebar's empty state, and -// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the -// cloned session's dir + command. +// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the +// cloned session (see spawnSimilar.ts). function requestOpenSpawn(): void { showMultiView(); // A new tab, not a split of the pane you were reading (#100). "Spawn @@ -279,7 +280,7 @@ document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener("catalog-spawn-similar", (ev) => { const s = (ev as CustomEvent).detail; showMultiView(); - mv.openDraft({ cwd: s.cwd, command: s.command }); + mv.openDraft(spawnSimilarSeed(s)); }); // Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 8055757..96fa2a1 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -589,7 +589,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwd = seedCwd; cwdRaw.value = seedCwd; // Restoring must not touch the worktree toggle — the seed carries it. - if (!restoreSelection(seedCwd)) clearProjectSelection(); + // A path no box matches (the project is gone, or the scan never saw + // it) falls back to the raw input showing that path: an empty picker + // with an invisible cwd reads as a form that lost the directory. + if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false); } else { showRawCwd(seedCwd, false); } diff --git a/web/src/spawnSimilar.test.ts b/web/src/spawnSimilar.test.ts new file mode 100644 index 0000000..e80e9ac --- /dev/null +++ b/web/src/spawnSimilar.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { spawnSimilarSeed } from "./spawnSimilar.js"; +import type { Session } from "./types.js"; + +function session(overrides: Partial = {}): Session { + return { + id: "s1", + cwd: "/repos/app", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...overrides, + } as Session; +} + +describe("spawnSimilarSeed (#28)", () => { + it("seeds the project picker, not the free-text hatch", () => { + const seed = spawnSimilarSeed(session()); + expect(seed.cwd).toBe("/repos/app"); + expect(seed.command).toBe("claude"); + expect(seed.cwdRawVisible).toBe(false); + }); + + it("keeps the worktree toggle off for a session that has no worktree", () => { + expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false); + }); + + it("clones a worktree session as a new worktree of its base repo", () => { + const seed = spawnSimilarSeed( + session({ + cwd: "/repos/app-worktrees/feature", + worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" }, + }), + ); + expect(seed.cwd).toBe("/repos/app"); + expect(seed.worktreeEnabled).toBe(true); + }); + + it("never carries the cloned session's name", () => { + const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } })); + expect(seed.name).toBeUndefined(); + }); +}); diff --git a/web/src/spawnSimilar.ts b/web/src/spawnSimilar.ts new file mode 100644 index 0000000..0d85f9b --- /dev/null +++ b/web/src/spawnSimilar.ts @@ -0,0 +1,30 @@ +// "Spawn similar" (#28): the seed a cloned session hands to a draft pane. +// Pure — the caller owns opening the pane. + +import type { Draft } from "./layout.js"; +import type { Session } from "./types.js"; + +/** Clone a session into draft values: same project, same command, same + * worktree decision, fresh name. + * + * A worktree session seeds its *base repository*, not the checkout the daemon + * made for it: "similar" is another session of that project, and the daemon + * never adopts an existing worktree (docs/WORKTREES.md), so seeding the + * checkout would ask it to build a worktree out of a worktree. The picker + * then lands on that repository's box with "+ new" active. + * + * `cwdRawVisible: false` keeps the two-step project picker on screen with the + * cloned project selected, instead of dropping the path into the free-text + * escape hatch. + * + * The name is left out on purpose: a clone is a new session and takes its own + * generated handle. */ +export function spawnSimilarSeed(s: Session): Partial { + const wt = s.worktree ?? null; + return { + command: s.command, + cwd: wt ? wt.base_repo : s.cwd, + worktreeEnabled: wt != null, + cwdRawVisible: false, + }; +} diff --git a/web/src/worktreeStep.test.ts b/web/src/worktreeStep.test.ts index 6fb1431..f84047d 100644 --- a/web/src/worktreeStep.test.ts +++ b/web/src/worktreeStep.test.ts @@ -240,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => { expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); expect(toggle(form).checked).toBe(false); }); + + it("lands a 'spawn similar' seed on the cloned project with '+ new' active", async () => { + urls = stubFetch([MAESTRO, WT_A, WT_B]); + handle = createSpawnForm({ + onSuccess() {}, + seed: { cwd: MAESTRO.path, cwdRawVisible: false, worktreeEnabled: true }, + }); + document.body.appendChild(handle.el); + await drain(); + const form = handle.el; + + expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); + expect(activeChoice(form)!.dataset.kind).toBe("new"); + expect(toggle(form).checked).toBe(true); + expect(form.querySelector(".cwd-raw-wrap")!.hidden).toBe(true); + }); + + it("falls back to the raw input when the seeded directory matches no project", async () => { + urls = stubFetch([MAESTRO, WT_A]); + handle = createSpawnForm({ + onSuccess() {}, + seed: { cwd: "/home/user/gone", cwdRawVisible: false, worktreeEnabled: false }, + }); + document.body.appendChild(handle.el); + await drain(); + const form = handle.el; + + const raw = form.querySelector(".cwd-raw")!; + expect(form.querySelector(".cwd-raw-wrap")!.hidden).toBe(false); + expect(raw.value).toBe("/home/user/gone"); + }); });