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;