From b9829bc56f0a7aebb30f33c0f1da8c9bde24c818 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:06:10 +0100 Subject: [PATCH] feat(maestro): project list replaces recents picker in spawn form (#59) --- web/src/api.ts | 10 ++- web/src/mock.ts | 47 +++++++--- web/src/projectPicker.test.ts | 162 ++++++++++++++++++++++++++++++++++ web/src/routes.ts | 1 + web/src/spawnForm.ts | 113 +++++++++++++++--------- web/src/styles.css | 47 ++++++++-- web/src/types.ts | 21 +++++ 7 files changed, 336 insertions(+), 65 deletions(-) create mode 100644 web/src/projectPicker.test.ts diff --git a/web/src/api.ts b/web/src/api.ts index 3a7c055..92c3b64 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -5,6 +5,7 @@ import type { DaemonInfo, DirListing, GitRoot, + Projects, RecentDirs, Session, Uuid, @@ -61,12 +62,17 @@ export async function getDaemonInfo(): Promise { return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" })); } -/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the - * spawn form's recents picker. */ +/** Recently-used working dirs, newest-first and deduped by dir (#26). */ export async function recentDirs(): Promise { return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" })); } +/** Every discovered git repository / worktree, newest-activity-first (#54). + * Drives the spawn form's project picker. */ +export async function projects(): Promise { + return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" })); +} + /** Probe the enclosing git repo of `path` (#26). `root` is absent when the path * isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves * it against its home dir. */ diff --git a/web/src/mock.ts b/web/src/mock.ts index e3847da..098978d 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -33,8 +33,8 @@ function sess( }; } -/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full - * tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only; +/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the + * full flow — presets, projects, gitroot — with no daemon running. Dev-only; * delegates every other request to the real `fetch`. */ function stubSpawnApi(): void { const real = globalThis.fetch.bind(globalThis); @@ -61,16 +61,39 @@ function stubSpawnApi(): void { }), ); } - if (path.includes("/recent-dirs")) { + if (path.includes("/projects")) { + const now = Date.now(); + const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString(); return Promise.resolve( - json({ - dirs: [ - { cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" }, - { cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" }, - { cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" }, - ], - truncated: false, - }), + json([ + { + path: "/home/dev/git/maestro", + name: "maestro", + is_worktree: false, + last_activity: iso(0), + last_command: "claude", + }, + { + path: "/home/dev/worktrees/maestro-59", + name: "maestro-59", + is_worktree: true, + base_repo: "/home/dev/git/maestro", + last_activity: iso(1), + last_command: "opencode", + }, + { + path: "/home/dev/git/dotfiles", + name: "dotfiles", + is_worktree: false, + last_activity: iso(30), + last_command: "zsh", + }, + { + path: "/home/dev/work/acme/api", + name: "api", + is_worktree: false, + }, + ]), ); } if (path.includes("/gitroot")) { @@ -113,7 +136,7 @@ export function seedMock(store: CatalogStore): void { }); // `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn - // backend endpoints stubbed so presets/recents/gitroot all render. + // backend endpoints stubbed so presets/projects/gitroot all render. if (location.search.includes("spawn")) { stubSpawnApi(); void import("./spawn.js").then((m) => m.openSpawnModal()); diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts new file mode 100644 index 0000000..f3e2b51 --- /dev/null +++ b/web/src/projectPicker.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; +import type { Project } from "./types.js"; + +const NOW = "2026-08-23T12:00:00Z"; + +function project(overrides: Partial): Project { + return { + path: `/home/user/${overrides.name ?? "proj"}`, + name: "proj", + is_worktree: false, + ...overrides, + }; +} + +/** Two fetches gate the form's initial load: /api/info then /api/projects. */ +function stubFetch(info: unknown, projects: Project[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/info") + ? info + : url.includes("/api/projects") + ? projects + : undefined; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +function rowPaths(form: HTMLFormElement): string[] { + return [...form.querySelectorAll(".project-row:not(.project-other)")].map( + (r) => r.dataset.cwd ?? "", + ); +} + +describe("spawnForm project picker", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse(NOW) }); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function mount(projects: Project[], seed?: Parameters[0]["seed"]) { + stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects); + handle = createSpawnForm({ + seed, + onSuccess() {}, + }); + document.body.appendChild(handle.el); + for (let i = 0; i < 20; i++) await Promise.resolve(); + return handle; + } + + it("renders one row per project, fresh before stale", async () => { + const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW }); + const stale = project({ + name: "stale", + path: "/home/user/stale", + 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(".project-row")]; + expect(rows[1].classList.contains("is-stale")).toBe(true); + expect(rows[0].classList.contains("is-stale")).toBe(false); + }); + + it("nests worktrees under their base repository", async () => { + const main = project({ name: "maestro", path: "/home/user/maestro" }); + const wt = project({ + name: "wt", + path: "/elsewhere/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); + }); + + it("filters rows as the operator types", 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 filter = form.querySelector(".project-filter")!; + filter.value = "tea"; + filter.dispatchEvent(new Event("input")); + expect(rowPaths(form)).toEqual(["/home/user/work/tea"]); + filter.value = "zzz"; + filter.dispatchEvent(new Event("input")); + expect(rowPaths(form)).toEqual([]); + expect(form.querySelector(".picker-empty")).toBeTruthy(); + }); + + it("picking a project selects its cwd and pre-selects its last command", async () => { + const p = project({ + name: "maestro", + path: "/home/user/maestro", + last_command: "claude", + }); + const onChange = vi.fn(); + stubFetch( + { + hostname: "h", + version: "0", + home_dir: "/home/user", + presets: [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "custom", label: "Custom…", command: "" }, + ], + }, + [p], + ); + handle = createSpawnForm({ onSuccess() {}, onChange }); + const form = handle.el; + document.body.appendChild(form); + for (let i = 0; i < 20; i++) await Promise.resolve(); + onChange.mockClear(); + + const row = form.querySelector(".project-row")!; + row.click(); + + const values = form.querySelector("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. + expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true); + expect(row.classList.contains("is-active")).toBe(true); + }); + + it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => { + const p = project({ name: "maestro", path: "/home/user/maestro" }); + const form = (await mount([p])).el; + const other = form.querySelector(".project-other")!; + other.click(); + expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false); + }); + + it("falls back to the raw input when no projects exist", async () => { + const form = (await mount([])).el; + expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false); + const raw = form.querySelector(".cwd-raw")!; + expect(raw.value).toBe("/home/user"); + }); +}); diff --git a/web/src/routes.ts b/web/src/routes.ts index 6fb38e2..1263611 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -9,6 +9,7 @@ export const SESSIONS = "/api/sessions"; export const INFO = "/api/info"; export const DIRS = "/api/dirs"; export const RECENT_DIRS = "/api/recent-dirs"; +export const PROJECTS = "/api/projects"; export const GITROOT = "/api/gitroot"; export const EVENTS = "/api/events"; diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index d28d295..916ca18 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -1,8 +1,9 @@ import { escapeHtml } from "./html.js"; -import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js"; +import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js"; +import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import { generateName } from "./names.js"; -import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js"; +import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import { abbreviateCwd, abbreviateFolderName } from "./types.js"; /** Seed values for the spawn form — used by "clone" to reopen with the same @@ -87,7 +88,7 @@ export interface SpawnFormOptions { onSuccess: (session: Session) => void; } -/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree. +/** The spawn form itself — preset tiles, project picker, name, worktree. * Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const prefill = opts.seed ?? {}; @@ -103,7 +104,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
Working directory -
+ +