From c8bf240624e43b6820713fc07b04cce0e98aa370 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Sat, 20 Jun 2026 22:05:48 +0000 Subject: [PATCH] feat(maestro): "Spawn similar" catalog action + spawn-history/gitroot e2e (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web: rename the per-row clone affordance to "Spawn similar" — it reopens the revamped spawn form pre-seeded from the session's host, dir, command (matched back to a preset by the form), and label, but with a freshly generated name since it's a new session. Built entirely on the existing prefill machinery; the custom event is renamed `catalog-spawn-similar`. e2e (maestro-e2e): against real tmux + git, spawn into a git repo dir and assert (1) the daemon's recent-dirs endpoint surfaces that dir with its command — what #27's recents picker reads on the next form open — proving the durable spawn-history write, and (2) gitroot infers the enclosing repo as the worktree base_repo. Closes #28 Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/maestro-e2e/tests/e2e.rs | 67 +++++++++++++++++++++++++++++++++ web/src/catalog.ts | 23 +++++------ web/src/kit.ts | 2 +- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index b3abb80..dc7e77e 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -455,6 +455,73 @@ async fn concurrent_spawns_each_get_unique_ids() { } } +#[tokio::test(flavor = "multi_thread")] +async fn spawn_records_history_and_infers_gitroot_for_form() { + // #24/#25/#26/#28: a real spawn writes a durable spawn-history record, which + // the daemon's recent-dirs endpoint surfaces on the spawn form's next open + // (#27's recents picker), and gitroot infers the worktree base_repo from the + // same dir. Exercised end to end against real tmux + git. + let (_reg, daemons, client) = bring_up(&["host"]).await; + let daemon_base = format!("http://{}", daemons[0].bind_addr); + + // A git repo as the working dir, so gitroot has a repo to infer. + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let cwd = repo.path().to_string_lossy().to_string(); + + // Spawn in that dir (the command stands in for a preset tile selection). + let s = client + .spawn_session(&create_req("host", repo.path(), "sleep 30")) + .await + .expect("spawn"); + + // The recents picker reads GET /recent-dirs on open: the just-spawned dir + // must surface, tagged with the command it was last used with. The history + // write is best-effort right after the spawn, so poll briefly. + wait_for_async( + "recent-dirs surfaces the spawned cwd", + Duration::from_secs(5), + || { + let url = format!("{daemon_base}/api/daemons/host/recent-dirs"); + let want = cwd.clone(); + async move { + let resp = match reqwest::get(&url).await { + Ok(r) => r, + Err(_) => return false, + }; + let recents: maestro_protocol::RecentDirs = match resp.json().await { + Ok(v) => v, + Err(_) => return false, + }; + recents + .dirs + .iter() + .any(|d| d.cwd.to_string_lossy() == want && d.last_command == "sleep 30") + } + }, + ) + .await; + + // gitroot infers the worktree base_repo from the same dir (#25/#26): the dir + // is itself the repo toplevel (it holds `.git`). + let gr: maestro_protocol::GitRoot = reqwest::Client::new() + .get(format!("{daemon_base}/api/daemons/host/gitroot")) + .query(&[("path", cwd.as_str())]) + .send() + .await + .expect("gitroot request") + .json() + .await + .expect("gitroot json"); + assert_eq!( + gr.root.as_deref().map(|p| p.to_string_lossy().to_string()), + Some(cwd.clone()), + "gitroot should infer the enclosing repo" + ); + + client.kill_session(s.id).await.expect("kill"); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/web/src/catalog.ts b/web/src/catalog.ts index b6df5a4..b73395f 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -118,16 +118,17 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { ${showCmd ? `${escapeHtml(s.command)}` : ""} ${running ? "" : `exited`} - + `; - const cloneBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; - cloneBtn.addEventListener("click", (ev) => { + const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; + similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - // The sidebar (which has the daemon list) opens the prefilled spawn modal. - document.dispatchEvent(new CustomEvent("catalog-clone", { detail: s })); + // The sidebar (which has the daemon list) opens the spawn modal pre-seeded + // from this session. + document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", async (ev) => { @@ -404,10 +405,11 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { const rerenderHandler = () => refresh(); document.addEventListener("catalog-rerender", rerenderHandler); - // Clone a session: reopen the spawn modal prefilled with its options. - const cloneHandler = (ev: Event) => { + // Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir, + // command (matched back to a preset by the form), and label — but with a + // freshly generated name rather than the original's, since it's a new session. + const spawnSimilarHandler = (ev: Event) => { const s = (ev as CustomEvent).detail; - const rawName = typeof s.metadata?.name === "string" ? s.metadata.name : ""; const snap = store.snapshot(); openSpawnModal( snap.daemons, @@ -415,13 +417,12 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { daemon_id: s.daemon_id, cwd: s.cwd, command: s.command, - name: rawName, label: sessionLabel(s) ?? "", }, snap.sessions, ); }; - document.addEventListener("catalog-clone", cloneHandler); + document.addEventListener("catalog-spawn-similar", spawnSimilarHandler); const unsubscribe = store.subscribe(refresh); @@ -433,7 +434,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { }, dispose() { document.removeEventListener("catalog-rerender", rerenderHandler); - document.removeEventListener("catalog-clone", cloneHandler); + document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler); unsubscribe(); }, }; diff --git a/web/src/kit.ts b/web/src/kit.ts index 7f04d19..73a055d 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -71,7 +71,7 @@ function leafRow(opts: { ${opts.cmd !== opts.name ? `${opts.cmd}` : ""} ${opts.status === "dead" ? `exited` : ""} - + `; return row;