feat(maestro): wire React app as sole web entry point

Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the
23 views it drove are gone; App.tsx composes the ported components —
sidebar, multi-view, command bar, spawn modal, help and restart
overlays, daemon link, drawer — around the catalog and layout stores.

The pure parts of four deleted modules moved next to the components
that still use them: the spawn-form model and spawnSimilar into
components/spawn, the transcript's markdown/format helpers and the diff
into components/structured, the help overlay's grouping into
components/dialogs. Document-level event names now live in uiEvents.ts.

Deduplicated two pairs the migration left side by side: the sidebar
uses dialogs/KillDialog and common/ContextMenu. It also listens for the
command bar's kill/rename events, which nothing had picked up since
catalog.ts went away.

styles.css stays as the token and base layer, with `#app` renamed to
`#root` and the pane-body rules re-keyed off the classes the React
panes actually carry.

Closes #253
This commit is contained in:
Miguel Palhas
2026-09-09 02:27:42 +01:00
parent ad71147bd9
commit 8a05ef2eb7
101 changed files with 855 additions and 18942 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup.
import type { BranchInfo } from "../../types.js";
import type { WorktreeBranchMode } from "../../spawnForm.js";
import type { WorktreeBranchMode } from "./model.js";
export interface BranchPickerProps {
enabled: boolean;
+1 -1
View File
@@ -42,7 +42,7 @@ import {
type SpawnFormSeed,
type SpawnFormValues,
type WorktreeBranchMode,
} from "../../spawnForm.js";
} from "./model.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "../../toolIcon.js";
import { abbreviateCwd } from "../../types.js";
import type { BranchInfo, Project, Session, SpawnPreset } from "../../types.js";
+1 -1
View File
@@ -5,4 +5,4 @@ export { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
export { useFastForward, type UseFastForwardOptions } from "./useFastForward.js";
// spawnSimilar.ts is pure seed-building logic with no DOM dependency — reused
// as-is rather than re-implemented for React (#248).
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "../../spawnSimilar.js";
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "./spawnSimilar.js";
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { buildSpawnRequest, type SpawnFormValues } from "./model.js";
const base: SpawnFormValues = {
command: "claude",
cwd: "/home/user/project",
name: "amber-fox",
structuredView: false,
autoApprove: false,
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
worktreeBranchEdited: false,
};
describe("buildSpawnRequest", () => {
it("builds a plain request with the picked cwd and command", () => {
const res = buildSpawnRequest(base, null);
expect(res).toEqual({
req: { cwd: "/home/user/project", command: "claude", metadata: { name: "amber-fox" } },
});
});
it("omits metadata.name when the name is blank", () => {
const res = buildSpawnRequest({ ...base, name: " " }, null);
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
});
it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
"requests structured view for resolved %s commands",
(command) => {
expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command,
metadata: { name: "amber-fox" },
view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
});
});
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
const res = buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
"/home/user/project",
);
expect(res).toEqual({
req: {
cwd: base.cwd,
command: base.command,
metadata: { name: "amber-fox" },
worktree: {
base_repo: "/home/user/project",
branch: "feat/foo",
create_branch: true,
},
},
});
});
it("checks out an existing branch when the mode is existing (#61)", () => {
const res = buildSpawnRequest(
{
...base,
worktreeEnabled: true,
worktreeBranch: "spawn-v2",
worktreeBranchMode: "existing",
},
"/home/user/project",
);
expect(res).toEqual({
req: {
cwd: base.cwd,
command: base.command,
metadata: { name: "amber-fox" },
worktree: {
base_repo: "/home/user/project",
branch: "spawn-v2",
create_branch: false,
},
},
});
});
it("rejects an existing-mode worktree with no branch picked", () => {
expect(
buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: "", worktreeBranchMode: "existing" },
"/repo",
),
).toHaveProperty("error");
});
it("rejects a missing working directory", () => {
expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error");
});
it("rejects a missing command", () => {
expect(buildSpawnRequest({ ...base, command: "" }, null)).toHaveProperty("error");
});
it("rejects a worktree without an inferred base repo", () => {
expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: "b" }, null))
.toHaveProperty("error");
});
it("rejects a worktree without a branch name", () => {
expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: " " }, "/repo"))
.toHaveProperty("error");
});
});
+104
View File
@@ -0,0 +1,104 @@
// Pure spawn-form model, kept when spawnForm.ts's imperative DOM form was
// deleted (#253): the value/seed shapes, the request builder, and the preset
// fallback. SpawnForm.tsx and useFastForward.ts render from these.
import { splitCommand } from "../../toolIcon.js";
import type { CreateSessionRequest, SpawnPreset } from "../../types.js";
export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
export function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "Spawn similar" to open the modal
* with the same cwd, command, and name as an existing session, and by the
* fast-forward fallback to pre-fill whatever it did resolve. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
name?: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
/** Whether the operator typed the branch themselves, rather than it having
* been auto-synced from the session name. Carried separately because the
* value alone cannot distinguish the two. */
worktreeBranchEdited?: boolean;
worktreeBranchMode?: WorktreeBranchMode;
cwdRawVisible?: boolean;
}
/** The form's current values, reported on every change. */
export interface SpawnFormValues {
command: string;
cwd: string;
name: string;
structuredView: boolean;
autoApprove: boolean;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
/** "new" creates the branch; "existing" checks out a picked one (#61). */
worktreeBranchMode: WorktreeBranchMode;
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
cwdRawVisible?: boolean;
}
export function buildSpawnRequest(
values: SpawnFormValues,
baseRepo: string | null,
): { req: CreateSessionRequest } | { error: string } {
if (!values.cwd) return { error: "Pick a working directory." };
if (!values.command) return { error: "Pick a command (or fill the custom command)." };
const metadata: Record<string, unknown> = {};
const name = values.name.trim();
if (name) metadata.name = name;
const req: CreateSessionRequest = {
cwd: values.cwd,
command: values.command,
metadata,
};
if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured";
}
if (values.autoApprove) {
req.auto_approve = true;
}
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
if (!branch) {
return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." };
}
req.worktree = {
base_repo: baseRepo,
branch,
// Existing-mode picks must check out as-is; the daemon creates
// anything else with `worktree add -b` (#56/#61).
create_branch: mode !== "existing",
};
}
return { req };
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (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" },
{ id: "custom", label: "Custom…", command: "" },
];
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "../../types.js";
function session(overrides: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repos/app",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...overrides,
} as Session;
}
describe("spawnSimilarSeed (#28)", () => {
it("seeds the project picker, not the free-text hatch", () => {
const seed = spawnSimilarSeed(session());
expect(seed.cwd).toBe("/repos/app");
expect(seed.command).toBe("claude");
expect(seed.cwdRawVisible).toBe(false);
});
it("keeps the worktree toggle off for a session that has no worktree", () => {
expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false);
});
it("clones a worktree session as a new worktree of its base repo", () => {
const seed = spawnSimilarSeed(
session({
cwd: "/repos/app-worktrees/feature",
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
}),
);
expect(seed.cwd).toBe("/repos/app");
expect(seed.worktreeEnabled).toBe(true);
});
it("never carries the cloned session's name", () => {
const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } }));
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");
});
});
+44
View File
@@ -0,0 +1,44 @@
// "Spawn similar" (#28): the seed a cloned session hands to the spawn modal.
// Pure — the caller owns opening the dialog.
import type { SpawnFormSeed } from "./model.js";
import type { Session } from "../../types.js";
/** Clone a session into form values: same project, same command, same
* worktree decision, fresh name.
*
* A worktree session seeds its *base repository*, not the checkout the daemon
* made for it: "similar" is another session of that project, and the daemon
* never adopts an existing worktree (docs/WORKTREES.md), so seeding the
* checkout would ask it to build a worktree out of a worktree. The picker
* then lands on that repository's box with "+ new" active.
*
* `cwdRawVisible: false` keeps the two-step project picker on screen with the
* cloned project selected, instead of dropping the path into the free-text
* escape hatch.
*
* The name is left out on purpose: a clone is a new session and takes its own
* generated handle. */
export function spawnSimilarSeed(s: Session): SpawnFormSeed {
const wt = s.worktree ?? null;
return {
command: s.command,
cwd: wt ? wt.base_repo : s.cwd,
worktreeEnabled: wt != null,
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,
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { useCallback } from "react";
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "../../fastForward.js";
import { FALLBACK_PRESETS, type SpawnFormSeed } from "../../spawnForm.js";
import { FALLBACK_PRESETS, type SpawnFormSeed } from "./model.js";
import type { Project, Session, SpawnPreset } from "../../types.js";
export interface UseFastForwardOptions {