From 9f582263b80ec8825ecf321a8eb09bf919917a89 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:02:16 +0100 Subject: [PATCH 1/2] feat(maestro): project list sort/group/filter helpers (#59) --- web/src/projectList.test.ts | 126 ++++++++++++++++++++++++++++++++++++ web/src/projectList.ts | 76 ++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 web/src/projectList.test.ts create mode 100644 web/src/projectList.ts diff --git a/web/src/projectList.test.ts b/web/src/projectList.test.ts new file mode 100644 index 0000000..cbf5ac9 --- /dev/null +++ b/web/src/projectList.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; +import type { Project } from "./types.js"; + +const NOW = new Date("2026-08-23T12:00:00Z"); + +function project(overrides: Partial): Project { + return { + path: `/home/user/${overrides.name ?? "proj"}`, + name: "proj", + is_worktree: false, + last_activity: undefined, + last_command: undefined, + ...overrides, + } as Project; +} + +describe("isStale", () => { + it("marks projects with no known activity stale", () => { + expect(isStale(project({ name: "never" }), NOW)).toBe(true); + }); + + it("is fresh within 7 days and stale beyond", () => { + const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" }); + const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" }); + expect(isStale(fresh, NOW)).toBe(false); + expect(isStale(stale, NOW)).toBe(true); + }); +}); + +describe("sortProjects", () => { + it("sorts newest-activity first", () => { + const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" }); + const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" }); + expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]); + }); + + it("sorts stale rows after fresh ones, each block newest first", () => { + const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" }); + const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" }); + const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" }); + const staleNone = project({ name: "stale-none" }); + const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW); + expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]); + }); + + it("does not mutate the input", () => { + const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" }); + const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" }); + sortProjects([a, b], NOW); + expect(a.name).toBe("a"); + }); +}); + +describe("groupProjects", () => { + it("keeps plain repositories as their own single-entry group", () => { + const solo = project({ name: "solo" }); + expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]); + }); + + it("nests worktrees under their base repository even when fresher than it", () => { + const main = project({ name: "maestro" }); + const wt = project({ + name: "wt", + path: "/elsewhere/wt", + is_worktree: true, + base_repo: main.path, + last_activity: "2026-08-23T00:00:00Z", + }); + const groups = groupProjects(sortProjects([wt, main], NOW)); + expect(groups).toHaveLength(1); + expect(groups[0].main).toBe(main); + expect(groups[0].worktrees).toEqual([wt]); + }); + + it("orders groups by the freshest member and worktrees by activity", () => { + const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" }); + const wtA = project({ + name: "a-wt", + path: "/w/a-wt", + is_worktree: true, + base_repo: "/w/a", + last_activity: "2026-08-20T00:00:00Z", + }); + const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" }); + const wtB1 = project({ + name: "b-wt1", + path: "/w/b-wt1", + is_worktree: true, + base_repo: "/w/b", + last_activity: "2026-08-19T00:00:00Z", + }); + const wtB2 = project({ + name: "b-wt2", + path: "/w/b-wt2", + is_worktree: true, + base_repo: "/w/b", + last_activity: "2026-08-21T00:00:00Z", + }); + + const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW)); + expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]); + const gA = groups.find((g) => g.main.name === "a")!; + const gB = groups.find((g) => g.main.name === "b")!; + expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]); + expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]); + }); +}); + +describe("filterProjects", () => { + const maestro = project({ name: "maestro", path: "/home/u/git/maestro" }); + const tea = project({ name: "tea", path: "/home/u/work/tea" }); + + it("matches on name case-insensitively", () => { + expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]); + }); + + it("matches on the full path", () => { + expect(filterProjects([maestro, tea], "work")).toEqual([tea]); + }); + + it("returns everything unchanged for an empty query", () => { + expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]); + }); +}); diff --git a/web/src/projectList.ts b/web/src/projectList.ts new file mode 100644 index 0000000..4a44ca6 --- /dev/null +++ b/web/src/projectList.ts @@ -0,0 +1,76 @@ +// Pure helpers behind the spawn form's project picker (#59): staleness, +// recency ordering, worktree grouping, and free-text filtering. The daemon +// reports the timestamps; what "stale" means is a client decision (#54). + +import type { Project } from "./types.js"; + +/** Projects whose last activity is older than this are dimmed and sort after + * fresh ones — still reachable, visibly stale (#59). */ +export const STALE_AFTER_DAYS = 7; + +export function isStale(project: Project, now: Date): boolean { + if (!project.last_activity) return true; + const age = now.getTime() - Date.parse(project.last_activity); + return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000; +} + +/** Fresh rows first, each block newest-activity-first; unknown or future + * timestamps sort as stalest. Ties fall back to name so the order is stable. */ +export function sortProjects(projects: Project[], now: Date): Project[] { + return [...projects].sort((a, b) => { + const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0); + if (staleDiff !== 0) return staleDiff; + const ta = timeOf(a); + const tb = timeOf(b); + if (ta !== tb) return tb - ta; + return a.name.localeCompare(b.name) || a.path.localeCompare(b.path); + }); +} + +function timeOf(project: Project): number { + return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY; +} + +/** One repository group: the main checkout followed by its linked worktrees. + * A group's position follows its freshest member, since a worktree worked on + * today should pull its (dormant) base repo up with it. */ +export interface ProjectGroup { + main: Project; + worktrees: Project[]; +} + +/** Group `sorted` projects by their base repository. A worktree nests under + * its main checkout even when the main sorts later; a main checkout missing + * from the list doesn't drop its worktrees. */ +export function groupProjects(sorted: Project[]): ProjectGroup[] { + const members = new Map(); + const keys: string[] = []; + for (const p of sorted) { + const key = p.base_repo ?? p.path; + let list = members.get(key); + if (!list) { + list = []; + members.set(key, list); + keys.push(key); + } + list.push(p); + } + return keys.map((key) => { + const list = members.get(key)!; + const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0]; + // Keep the daemon's newest-first order within the group. + const worktrees = list + .filter((p) => p !== main) + .sort((a, b) => timeOf(b) - timeOf(a)); + return { main, worktrees }; + }); +} + +/** Substring filter over project name and full path, case-insensitive. */ +export function filterProjects(projects: Project[], query: string): Project[] { + const q = query.trim().toLowerCase(); + if (!q) return [...projects]; + return projects.filter( + (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q), + ); +} From b9829bc56f0a7aebb30f33c0f1da8c9bde24c818 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:06:10 +0100 Subject: [PATCH 2/2] 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 -
+ +