diff --git a/web/src/catalog.ts b/web/src/catalog.ts index b96f4f5..db6186d 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -196,6 +196,8 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; + // The keyboard pair sits right here in the tooltip (#67). + spawnBtn.title = "Draft pane: n — fast-forward spawn: N"; spawnBtn.addEventListener("click", () => requestOpenSpawn()); actions.appendChild(spawnBtn); return header; diff --git a/web/src/main.ts b/web/src/main.ts index 27de74c..d393b84 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js"; import { installHudKeybind } from "./debug.js"; import { CatalogStore } from "./events.js"; import { shouldRetryClose } from "./reconnect.js"; +import { + createSession, + fetchNames, + getDaemonInfo, + projects as fetchProjects, +} from "./api.js"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import { FALLBACK_PRESETS } from "./spawnForm.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; -import { sessionName, type Session } from "./types.js"; +import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -212,6 +224,7 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; + spawn.title = "Draft pane: n — fast-forward spawn: N"; spawn.addEventListener("click", () => requestOpenSpawn()); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; @@ -265,6 +278,65 @@ document.addEventListener("catalog-spawn-similar", (ev) => { mv.openDraft({ cwd: s.cwd, command: s.command }); }); +// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns +// immediately with every default resolved — most recent project, its last +// command (or the first non-custom preset), a fresh name, worktree on with +// a new branch named after that name. Anything unresolvable (no projects +// known yet, no command, no generated name) opens a draft pre-filled with +// what did resolve instead. +async function requestFastForward(): Promise { + showMultiView(); + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + let presets: SpawnPreset[] = FALLBACK_PRESETS; + try { + const info = await getDaemonInfo(); + if (info.presets?.length) presets = info.presets; + } catch { + /* keep fallbacks */ + } + const resolved = resolveFastForward(discovered, presets, name); + const req = buildFastForwardRequest(resolved); + if (req) { + try { + await createSession(req); + return; + } catch { + // A failed POST degrades to the same pre-filled draft as an + // unresolvable default — never a silent dead keystroke. + } + } + mv.openDraft(fastForwardSeed(resolved)); +} + +// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn. +// Ignored while typing anywhere (form fields, the terminal's hidden +// textarea, search) so an attached session's keys are never hijacked. +document.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.altKey || e.metaKey) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + if (e.key === "n") { + e.preventDefault(); + requestOpenSpawn(); + } else if (e.key === "N") { + e.preventDefault(); + void requestFastForward(); + } +}); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 238d31a..b64fd0d 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -83,8 +83,9 @@ export function buildSpawnRequest( } /** Built-ins used when the daemon's `/api/info` presets can't be fetched - * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ -const FALLBACK_PRESETS: SpawnPreset[] = [ + * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. + * Exported for the fast-forward spawn's preset fallback (#67). */ +export const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "claude", label: "Claude", command: "claude" }, { id: "opencode", label: "opencode", command: "opencode" }, { id: "zsh", label: "Shell", command: "zsh" },