feat(maestro): project list replaces recents picker in spawn form (#59)

This commit is contained in:
Miguel Palhas
2026-08-23 21:06:10 +01:00
parent 9f582263b8
commit b9829bc56f
7 changed files with 336 additions and 65 deletions
+8 -2
View File
@@ -5,6 +5,7 @@ import type {
DaemonInfo,
DirListing,
GitRoot,
Projects,
RecentDirs,
Session,
Uuid,
@@ -61,12 +62,17 @@ export async function getDaemonInfo(): Promise<DaemonInfo> {
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<RecentDirs> {
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<Projects> {
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. */
+35 -12
View File
@@ -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());
+162
View File
@@ -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>): 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<HTMLButtonElement>(".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<typeof createSpawnForm>[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<HTMLButtonElement>(".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<HTMLInputElement>(".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<HTMLButtonElement>(".project-row")!;
row.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.
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<HTMLButtonElement>(".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<HTMLInputElement>(".cwd-raw")!;
expect(raw.value).toBe("/home/user");
});
});
+1
View File
@@ -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";
+69 -44
View File
@@ -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 {
</div>
<div class="field">
<span class="field-label">Working directory</span>
<div class="picker recents"></div>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
@@ -143,7 +146,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".recents") as HTMLElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
@@ -161,7 +165,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let probeToken = 0; // guards async gitroot probes against races
// A seed's cwd, applied once the (async) recents load for the initial host
// A seed's cwd, applied once the (async) projects load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let seedCwd: string | null = prefill.cwd ?? null;
@@ -292,13 +296,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveRecent(null);
markActiveProject(null);
if (focus) {
cwdRaw.focus();
cwdRaw.select();
}
// Both the visibility and the directory are draft state: without this a
// render before the operator types snaps back to the recents picker.
// render before the operator types snaps back to the project picker.
emit();
}
@@ -308,8 +312,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (wtEnabled.checked) probeGitRoot();
});
function markActiveRecent(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".recent-row")) {
function markActiveProject(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".project-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
@@ -317,46 +321,62 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
}
function selectRecent(dir: RecentDir): void {
cwd = dir.cwd;
function selectProject(project: Project): void {
cwd = project.path;
cwdRawWrap.hidden = true;
markActiveRecent(dir.cwd);
markActiveProject(project.path);
emit();
// Picking a recent dir can pre-select the command it was last used with.
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
// Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
if (match) selectPreset(match.id);
if (wtEnabled.checked) probeGitRoot();
}
async function loadRecents(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
function renderProjects(): void {
recentsEl.replaceChildren();
for (const dir of dirs) {
recentsEl.appendChild(recentRow(dir));
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
}
// Persistent escape hatch + empty-history fallback.
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
}
filterInput.addEventListener("input", renderProjects);
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
discovered = [];
}
if (destroyed) return;
renderProjects();
if (seedCwd) {
// A seed sets the directory verbatim, regardless of recorded history.
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked recent row: keep the picker and don't steal focus. Otherwise
// land focused in the raw input (clone / empty-history flows).
// picked row: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveRecent(cwd);
markActiveProject(cwd);
} else {
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
} else if (discovered.length === 0) {
// Nothing discovered yet: default to raw input prefilled with home.
showRawCwd(homeDir, false);
} else {
cwdRawWrap.hidden = true;
@@ -367,27 +387,32 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
function recentRow(dir: RecentDir): HTMLElement {
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row";
row.dataset.cwd = dir.cwd;
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
isStale(project, new Date()) ? " is-stale" : ""
}`;
row.dataset.cwd = project.path;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
row.addEventListener("click", () => selectRecent(dir));
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
row.className = "project-row project-other";
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
@@ -395,7 +420,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
function loadingRow(): HTMLElement {
const p = document.createElement("p");
p.className = "picker-loading";
p.textContent = "Loading recent directories…";
p.textContent = "Loading projects…";
return p;
}
@@ -458,8 +483,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
// Seed ordering: the raw-cwd application races the async info load (which
// provides homeDir for the recents' abbreviations), so recents wait for it.
void infoLoad.then(() => loadRecents());
// provides homeDir for the projects' abbreviations), so projects wait for it.
void infoLoad.then(() => loadProjects());
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
+40 -7
View File
@@ -1448,7 +1448,8 @@ textarea {
color: var(--muted);
}
/* Recent-dirs picker rows. */
/* Project picker rows (#59). Worktree rows are indented under their base
repository; rows stale beyond 7 days are dimmed but still selectable. */
.picker {
display: flex;
flex-direction: column;
@@ -1459,7 +1460,11 @@ textarea {
scrollbar-color: var(--border-strong) transparent;
}
.recent-row {
.project-filter {
width: 100%;
}
.project-row {
display: flex;
align-items: baseline;
justify-content: space-between;
@@ -1477,18 +1482,40 @@ textarea {
background var(--dur-fast) var(--ease-out);
}
.recent-row:hover {
.project-row:hover {
border-color: var(--border-strong);
}
.recent-row.is-active {
.project-row.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-row.is-worktree {
margin-left: var(--sp-4);
}
.project-row.is-stale {
opacity: 0.55;
}
.project-info {
display: flex;
align-items: baseline;
gap: var(--sp-2);
min-width: 0;
}
.project-name {
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
}
.recent-dir {
font-family: var(--font-mono);
font-size: var(--fs-14);
font-size: var(--fs-12);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1501,12 +1528,18 @@ textarea {
color: var(--muted);
}
.recent-other .recent-dir {
color: var(--muted);
.project-other .recent-dir {
font-family: inherit;
font-style: italic;
}
.picker-empty {
margin: 0;
padding: 6px var(--sp-3);
color: var(--faint);
font-size: var(--fs-13);
}
.picker-loading {
margin: 0;
padding: 6px var(--sp-3);
+21
View File
@@ -83,6 +83,27 @@ export interface GitRoot {
root?: string;
}
/** One discovered git repository (or linked worktree) for `GET /api/projects`
* (#54). Mirrors `maestro_protocol::Project`. */
export interface Project {
/** Absolute path of the repository/worktree toplevel. */
path: string;
/** Basename of `path`. */
name: string;
is_worktree?: boolean;
/** The main checkout this worktree belongs to; absent on plain repos. */
base_repo?: string;
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
* any directory inside it; absent when neither is known. */
last_activity?: IsoTimestamp;
/** Command last spawned in this project, from spawn history. */
last_command?: string;
}
/** `GET /api/projects` — every discovered project, sorted by the daemon
* newest-activity-first (#54). */
export type Projects = Project[];
export interface WorktreeSpec {
base_repo: string;
branch: string;