feat(maestro): open the spawn form on the project picker

The picker now leads the form in both clients and takes focus on open, so
Tab walks from it into the command tiles. Opening the form also preselects
the project of the session you were last on — a worktree session seeds its
base repository — so 'n' then Enter spawns another session there.
This commit is contained in:
Miguel Palhas
2026-09-02 15:56:35 +01:00
parent f89be4a716
commit 247060de68
9 changed files with 285 additions and 66 deletions
+9 -2
View File
@@ -22,7 +22,7 @@ import {
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { openSpawnModal } from "./spawnModal.js";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
@@ -243,7 +243,14 @@ function requestOpenSpawn(): void {
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
// the old behaviour: both act on the session in front of you, so a split
// beside it is the point.
openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
// The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker.
const focused = mv?.focusedSessionId() ?? null;
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }),
});
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
+14 -8
View File
@@ -122,12 +122,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -139,6 +133,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
@@ -896,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
e.preventDefault();
if (target === filterInput) {
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
// Resolve the filter to a project only when the current pick isn't one
// of the matches: a filter typed past it means the operator moved on,
// while a pick that still matches (including the seeded one the form
// opens with) keeps its worktree choice.
const visible = visibleGroups(filterInput.value);
const stillMatches =
selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path);
if (!stillMatches && visible[0]) selectProject(visible[0]);
}
form.requestSubmit();
});
+23 -2
View File
@@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
async function mount(
projects: Project[],
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ onSuccess() {} });
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
@@ -232,6 +235,24 @@ describe("spawnForm Enter-to-submit (#97)", () => {
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("submits in the seeded project rather than the freshest one", async () => {
// What `n` then Enter must do: the modal opens parked on the project of
// the session you were last on (OTHER here), even though MAESTRO's
// worktree is the freshest row in the list.
const form = await mount([MAESTRO, WT_A, OTHER], {
cwd: OTHER.path,
cwdRawVisible: false,
});
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
+10
View File
@@ -52,6 +52,16 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("opens with the project filter focused, ahead of the command tiles", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
});
it("titles a seeded open as a clone", async () => {
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
await flush();
+3
View File
@@ -59,6 +59,9 @@ export function openSpawnModal(opts: SpawnModalOptions): void {
},
});
dialog.appendChild(form.el);
// The project filter owns the keyboard on open: typing narrows the list and
// Tab from here reaches the command tiles, matching the TUI's draft pane.
form.el.querySelector<HTMLInputElement>(".project-filter")?.focus();
function close(): void {
if (closeCurrent === close) closeCurrent = null;
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "./types.js";
function session(overrides: Partial<Session> = {}): Session {
@@ -43,3 +43,23 @@ describe("spawnSimilarSeed (#28)", () => {
expect(seed.name).toBeUndefined();
});
});
describe("spawnSimilarProjectSeed", () => {
it("seeds the project alone, leaving command and worktree at their defaults", () => {
const seed = spawnSimilarProjectSeed(session());
expect(seed.cwd).toBe("/repos/app");
expect(seed.cwdRawVisible).toBe(false);
expect(seed.command).toBeUndefined();
expect(seed.worktreeEnabled).toBeUndefined();
});
it("seeds a worktree session's base repository", () => {
const seed = spawnSimilarProjectSeed(
session({
cwd: "/repos/app-worktrees/feature",
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
}),
);
expect(seed.cwd).toBe("/repos/app");
});
});
+14
View File
@@ -28,3 +28,17 @@ export function spawnSimilarSeed(s: Session): SpawnFormSeed {
cwdRawVisible: false,
};
}
/** The project half of a clone: the session's repository, nothing else.
*
* The plain spawn modal opens on this so the picker is already parked on the
* project you were last looking at — one Enter spawns there — while the
* command, worktree decision and name stay at the form's own defaults. Same
* base-repository rule as `spawnSimilarSeed`. */
export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed {
const wt = s.worktree ?? null;
return {
cwd: wt ? wt.base_repo : s.cwd,
cwdRawVisible: false,
};
}