feat(maestro): pick a project, then its worktree

The working-directory picker was a flat list mixing repositories with
their worktrees, each row repeating the project name as an abbreviated
path. It is now two steps: a grid of project boxes carrying the full
unabbreviated path, then — only once a project is picked — that
repository's worktrees with "+ new" as the default.

"+ new" creates a worktree; picking an existing one runs in it and
leaves the create-worktree toggle off, since the daemon never adopts
(docs/WORKTREES.md). That is #91's rule, now an explicit choice rather
than an inference from which row was clicked.

Also fixes `.raw-wrap[hidden]`: `display: flex` outranked the UA sheet's
`[hidden]`, so the free-text cwd input showed even when hidden.

The interaction rules are listed in spawnForm.ts for the TUI mirror (#96)
to follow.

Refs #95
This commit is contained in:
Miguel Palhas
2026-08-24 15:08:05 +01:00
parent 2a2da7f779
commit 2c89902829
5 changed files with 559 additions and 137 deletions
+41 -25
View File
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
);
}
function rowPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
(r) => r.dataset.cwd ?? "",
function boxPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
(b) => b.dataset.cwd ?? "",
);
}
describe("spawnForm project picker", () => {
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
return handle;
}
it("renders one row per project, fresh before stale", async () => {
it("renders one box per project, fresh before stale", async () => {
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
const stale = project({
name: "stale",
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
last_activity: "2026-01-01T00:00:00Z",
});
const form = (await mount([stale, fresh])).el;
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
expect(rows[1].classList.contains("is-stale")).toBe(true);
expect(rows[0].classList.contains("is-stale")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("nests worktrees under their base repository", async () => {
it("shows the name as the primary line and the full path unabbreviated", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
// projects sharing a basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
const main = project({ name: "maestro", path: "/home/user/maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
path: "/home/user/maestro/worktrees/wt",
is_worktree: true,
base_repo: main.path,
});
const form = (await mount([wt, main])).el;
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
const [mainRow, wtRow] = [
...form.querySelectorAll(".project-row:not(.project-other)"),
];
expect(wtRow.classList.contains("is-worktree")).toBe(true);
expect(mainRow.classList.contains("is-worktree")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
});
it("filters rows as the operator types", async () => {
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
const a = project({ name: "maestro", path: "/home/user/maestro" });
const b = project({ name: "tea", path: "/home/user/work/tea" });
const form = (await mount([a, b])).el;
const wt = project({
name: "bulgarians",
path: "/home/user/maestro/worktrees/bulgarians",
is_worktree: true,
base_repo: a.path,
});
const form = (await mount([a, b, wt])).el;
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "tea";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
// A query that only matches a worktree still surfaces its repository box.
filter.value = "bulgarians";
filter.dispatchEvent(new Event("input"));
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual([]);
expect(boxPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
row.click();
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked row is the source of truth.
// 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(row.classList.contains("is-active")).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
});
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {