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
@@ -7,7 +7,7 @@ import {
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../catalog.js";
} from "../../uiEvents.js";
import { COMMAND_SPECS, type Command } from "../../commands.js";
import type { Session } from "../../types.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../catalog.js";
} from "../../uiEvents.js";
import { publishedCommands, type Command } from "../../commands.js";
import { sessionName, type Session } from "../../types.js";
import { ActionPage } from "./ActionPage.js";
@@ -2,7 +2,7 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
let controls: ReturnType<typeof useCommandBar>;
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useState } from "react";
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
export interface UseCommandBarOptions {
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { groupedCommands } from "./model.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { groupedCommands } from "./model.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
+1
View File
@@ -1,2 +1,3 @@
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
export { HelpOverlay } from "./HelpOverlay.js";
export { groupedCommands } from "./model.js";
+20
View File
@@ -0,0 +1,20 @@
// Command-registry grouping for the help overlay. Moved out of help.ts (whose
// imperative DOM overlay #253 deleted) so HelpOverlay.tsx renders the same
// grouping the vanilla overlay did.
import { publishedCommands, type Command } from "../../commands.js";
/** Published commands bucketed by `group`, in registry order — the order the
* overlay lists them in. */
export function groupedCommands(): { title: string; items: Command[] }[] {
const groups: { title: string; items: Command[] }[] = [];
for (const cmd of publishedCommands()) {
let group = groups.find((g) => g.title === cmd.group);
if (!group) {
group = { title: cmd.group, items: [] };
groups.push(group);
}
group.items.push(cmd);
}
return groups;
}
+2 -2
View File
@@ -2,8 +2,8 @@
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js";
import { TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
import { Drawer } from "./Drawer.js";
/** Force the mobile media query on, whatever happy-dom's viewport says
+1 -2
View File
@@ -10,8 +10,7 @@
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
export interface DrawerProps {
sidebar: ReactNode;
+38 -4
View File
@@ -4,17 +4,26 @@
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useStore } from "zustand";
import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
/** Reaching the focused pane from outside the tree — what the shell's
* leader chords need (focus.toggle, the literal-byte escape hatches). */
export interface MultiViewHandle {
/** Hand the keyboard to the focused pane's terminal (or composer). */
focusPane(): void;
/** Type into the focused pane. A structured pane has no PTY and ignores it. */
sendInput(data: string): void;
}
export interface MultiViewProps {
store: LayoutStoreApi;
/** Live sessions, keyed by id — drives pane titles and (once known) boot
@@ -24,9 +33,26 @@ export interface MultiViewProps {
emptyState?: ReactNode;
}
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
export const MultiView = forwardRef<MultiViewHandle, MultiViewProps>(function MultiView(
{ store, sessions, onDisconnect, emptyState },
ref,
) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const tab = activeTab(layout);
// Live pane handles, keyed by leaf. Read at call time (not render time) so
// the chords always reach whatever is focused right now.
const panes = useRef(new Map<string, PaneContainerHandle>()).current;
const focusedLeafRef = useRef<string | null>(null);
focusedLeafRef.current = tab.focusedLeafId;
useImperativeHandle(
ref,
() => ({
focusPane: () => panes.get(focusedLeafRef.current ?? "")?.focusTerminal(),
sendInput: (data: string) => panes.get(focusedLeafRef.current ?? "")?.sendInput(data),
}),
[panes],
);
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
const isPreviewing = previewedSessionId !== null;
@@ -45,6 +71,7 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
<SplitNode
node={tab.root}
tab={tab}
panes={panes}
store={store}
sessions={sessions}
onDisconnect={onDisconnect}
@@ -69,10 +96,12 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
</div>
</div>
);
}
});
interface TreeProps {
tab: { id: string; focusedLeafId: string | null };
/** Pane handles by leaf id — each `Pane` registers and unregisters its own. */
panes: Map<string, PaneContainerHandle>;
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
@@ -145,6 +174,7 @@ function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { k
function Pane({
leaf,
isFocused,
panes,
store,
sessions,
onDisconnect,
@@ -221,6 +251,10 @@ function Pane({
<div className="mv-pane-host min-h-0 flex-1">
{leaf.sessionId ? (
<PaneContainer
ref={(handle) => {
if (handle) panes.set(leaf.id, handle);
else panes.delete(leaf.id);
}}
sessionId={leaf.sessionId}
session={session}
focus={isFocused && previewedSessionId === null}
@@ -1,64 +0,0 @@
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
danger?: boolean;
onSelect(): void;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown, true);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown, true);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: x, top: y }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "text-danger" : "text-ink"
}`}
onClick={() => {
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
@@ -1,63 +0,0 @@
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
// confirmation names what the card last saw in the way — the daemon refuses an
// unforced kill over such a tree, and saying so beforehand is cheaper than the
// refusal. Cancel holds the focus so a stray Enter kills nothing.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { InTheWay } from "../../types.js";
import { formatInTheWay } from "../../types.js";
export interface KillConfirmProps {
name: string;
blockers: InTheWay | null;
/** Set when the daemon already refused an unforced kill: this pass forces. */
force?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function KillConfirm(props: KillConfirmProps) {
const cancel = useRef<HTMLButtonElement>(null);
useEffect(() => cancel.current?.focus(), []);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={props.force ? "Force kill session" : "Kill session"}
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onKeyDown={(e) => {
if (e.key === "Escape") props.onCancel();
}}
>
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<p className="text-fs-13 text-ink">
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
</p>
{props.blockers && (
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
)}
<div className="mt-sp-3 flex justify-end gap-sp-2">
<button
ref={cancel}
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={props.onCancel}
>
Cancel
</button>
<button
type="button"
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={props.onConfirm}
>
{props.force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+37 -7
View File
@@ -10,9 +10,10 @@ import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT } from "../../uiEvents.js";
import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
import { FolderNode } from "./FolderNode.js";
import { KillConfirm } from "./KillConfirm.js";
import { KillDialog } from "../dialogs/KillDialog.js";
import { SessionCard } from "./SessionCard.js";
import { SidebarSearch } from "./SidebarSearch.js";
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
@@ -58,6 +59,8 @@ interface KillState {
session: Session;
blockers: InTheWay | null;
force: boolean;
/** The daemon's refusal message, once an unforced kill has been refused. */
reason?: string;
}
export function Sidebar(props: SidebarProps) {
@@ -158,6 +161,27 @@ export function Sidebar(props: SidebarProps) {
return sessions.find((s) => s.id === row.id);
}, [rows, navKey, sessions]);
// The command bar's action page raises these rather than reimplementing
// either flow (docs/COMMAND-BAR.md): a kill from the bar goes through the
// same confirm-and-blockers dialog the card's ✕ does, and a rename from the
// bar opens the card's own inline field.
useEffect(() => {
const onKillRequest = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) beginKill(s);
};
const onRenameRequest = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) setRenamingId(s.id);
};
document.addEventListener(KILL_SESSION_EVENT, onKillRequest);
document.addEventListener(RENAME_SESSION_EVENT, onRenameRequest);
return () => {
document.removeEventListener(KILL_SESSION_EVENT, onKillRequest);
document.removeEventListener(RENAME_SESSION_EVENT, onRenameRequest);
};
}, [beginKill]);
const onKeyDown = (e: React.KeyboardEvent) => {
// The inline rename field owns its own keys; the walk stays out of it.
if (e.target instanceof HTMLInputElement) return;
@@ -296,15 +320,18 @@ export function Sidebar(props: SidebarProps) {
/>
)}
{kill && (
<KillConfirm
<KillDialog
name={sessionName(kill.session)}
blockers={kill.blockers}
force={kill.force}
reason={kill.reason}
onCancel={() => setKill(null)}
onConfirm={() => {
onConfirm={async () => {
const { session: s, force } = kill;
setKill(null);
void killSession(s.id, force).catch((e: unknown) => {
try {
await killSession(s.id, force);
setKill(null);
} catch (e: unknown) {
// The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md).
@@ -313,9 +340,12 @@ export function Sidebar(props: SidebarProps) {
session: s,
blockers: e.unsavedWork ?? sessionBlockers(s),
force: true,
reason: e.message,
});
return;
}
});
throw e;
}
}}
/>
)}
+1 -2
View File
@@ -3,6 +3,5 @@ export { SessionCard, type SessionCardProps } from "./SessionCard.js";
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
export { ActivityRail } from "./ActivityRail.js";
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
export { KillConfirm } from "./KillConfirm.js";
export { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
export * from "./model.js";
+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 {
+2 -2
View File
@@ -1,10 +1,10 @@
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
// computed here (structuredDiff.ts) rather than parsed out of a patch.
// computed here (diff.ts) rather than parsed out of a patch.
import { useMemo } from "react";
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
import { computeDiffLines, diffStats, type DiffLine } from "./diff.js";
import type { AcpDiff } from "../../types.js";
import { Copyable } from "./Copyable.js";
@@ -25,7 +25,7 @@ import {
setStructuredConfig,
uploadStructuredFile,
} from "../../api.js";
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
import { formatContextTokens, formatElapsed } from "./render.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
@@ -7,7 +7,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
import type {
AcpContentBlock,
AcpPermissionOption,
@@ -35,7 +35,7 @@ import { ToolIcon } from "./ToolIcon.js";
// content rows: user / agent / thought
// ---------------------------------------------------------------------------
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
/** Model-authored markdown, sanitized by render.ts before it enters
* the DOM. Tables get their own scroller and links open out of the app. */
function Prose({ html }: { html: string }) {
const ref = useRef<HTMLDivElement | null>(null);
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { computeDiffLines, diffStats, type DiffLine } from "./diff.js";
function texts(lines: DiffLine[]): Array<[string, string]> {
return lines.map((l) => [l.kind, l.text]);
}
describe("computeDiffLines", () => {
it("renders a brand new file (no oldText) as all additions", () => {
const lines = computeDiffLines(null, "a\nb\n");
expect(texts(lines)).toEqual([
["add", "a"],
["add", "b"],
]);
});
it("renders a full deletion as all removals", () => {
const lines = computeDiffLines("a\nb\n", "");
expect(texts(lines)).toEqual([
["del", "a"],
["del", "b"],
]);
});
it("shows a small replace as context around del/add", () => {
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
expect(texts(lines)).toEqual([
["ctx", "one"],
["del", "two"],
["add", "twx"],
["ctx", "three"],
]);
});
it("leaves identical text as pure context, uncollapsed when short", () => {
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
expect(texts(lines)).toEqual([
["ctx", "a"],
["ctx", "b"],
["ctx", "c"],
]);
});
it("handles an insertion without changing surrounding lines", () => {
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
["ctx", "a"],
["add", "b"],
["ctx", "c"],
]);
});
it("handles a deletion without changing surrounding lines", () => {
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
["ctx", "a"],
["del", "b"],
["ctx", "c"],
]);
});
it("renders two empty files without invented lines", () => {
expect(computeDiffLines("", "")).toEqual([]);
});
it("shows when a final newline is added", () => {
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
["del", "a"],
["eof", "\\ No newline at end of file"],
["add", "a"],
]);
});
it("shows when a final newline is removed", () => {
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
["del", "a"],
["add", "a"],
["eof", "\\ No newline at end of file"],
]);
});
it("marks an unchanged unterminated final context line", () => {
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
["ctx", "a"],
["del", "b"],
["add", "X"],
["ctx", "c"],
["eof", "\\ No newline at end of file"],
]);
});
it("marks an unterminated new file only once", () => {
const lines = computeDiffLines(null, "a");
expect(texts(lines)).toEqual([
["add", "a"],
["eof", "\\ No newline at end of file"],
]);
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
});
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
const lines = computeDiffLines(oldText, newText);
// The leading run keeps only its last 3 lines (adjacent to the change),
// behind a marker for the rest; the trailing run mirrors that.
expect(lines[0].kind).toBe("hunk");
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
expect(lines[4].kind).toBe("del");
expect(lines[5].kind).toBe("add");
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
});
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
const oldText = ["OLD", ...trailing].join("\n");
const newText = ["NEW", ...trailing].join("\n");
expect(texts(computeDiffLines(oldText, newText))).toEqual([
["del", "OLD"],
["add", "NEW"],
["ctx", "t0"],
["ctx", "t1"],
["ctx", "t2"],
["hunk", "⋯ 7 unchanged lines ⋯"],
["eof", "\\ No newline at end of file"],
]);
});
it("produces one hunk for the long middle run, none for the short edges", () => {
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
const lines = computeDiffLines(oldText, newText);
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
expect(dels).toEqual(["OLD1", "OLD2"]);
expect(adds).toEqual(["NEW1", "NEW2"]);
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
expect(texts(lines).slice(0, 2)).toEqual([
["ctx", "lead0"],
["ctx", "lead1"],
]);
expect(texts(lines).slice(-3)).toEqual([
["ctx", "trail0"],
["ctx", "trail1"],
["eof", "\\ No newline at end of file"],
]);
});
it("handles an old file with no trailing newline", () => {
const lines = computeDiffLines("a\nb", "a\nb\nc");
expect(texts(lines)).toEqual([
["ctx", "a"],
["del", "b"],
["eof", "\\ No newline at end of file"],
["add", "b"],
["add", "c"],
["eof", "\\ No newline at end of file"],
]);
});
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
const newLines = [...oldLines.slice(1), oldLines[0]];
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
expect(lines).toHaveLength(1_002);
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
});
});
describe("diffStats", () => {
it("counts add/del lines, ignoring context and hunk markers", () => {
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
});
it("is zero for identical text", () => {
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
});
});
+167
View File
@@ -0,0 +1,167 @@
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
// coloured hunks the design calls for have to be computed here.
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
export interface DiffLine {
kind: DiffLineKind;
/** The line's text (a `hunk` line is a human summary, not source text). */
text: string;
}
/** Interior unchanged runs longer than this collapse to a summary line
* flanked by this many lines of real context on each side. */
const CONTEXT = 3;
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
* shown as one coarse del-then-add block instead of a fine-grained diff —
* still correct, just not minimal. */
const LCS_CELL_LIMIT = 250_000;
interface SourceLine {
text: string;
terminated: boolean;
}
function splitLines(text: string): SourceLine[] {
if (text === "") return [];
const parts = text.split("\n");
const hasFinalNewline = text.endsWith("\n");
if (hasFinalNewline) parts.pop();
return parts.map((line, index) => ({
text: line,
terminated: index < parts.length - 1 || hasFinalNewline,
}));
}
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
function sameLine(a: SourceLine, b: SourceLine): boolean {
return a.text === b.text && a.terminated === b.terminated;
}
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
* shared prefix/suffix by the caller. */
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
const n = a.length;
const m = b.length;
if (n === 0) return b.map((line) => ({ kind: "add", line }));
if (m === 0) return a.map((line) => ({ kind: "del", line }));
if (n * m > LCS_CELL_LIMIT) {
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
}
// dp[i][j] = length of the LCS of a[i:] and b[j:].
const columns = m + 1;
const dp = new Uint32Array((n + 1) * columns);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * columns + j] = sameLine(a[i], b[j])
? dp[(i + 1) * columns + j + 1] + 1
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
}
}
const ops: Op[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (sameLine(a[i], b[j])) {
ops.push({ kind: "ctx", line: a[i] });
i++;
j++;
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
ops.push({ kind: "del", line: a[i] });
i++;
} else {
ops.push({ kind: "add", line: b[j] });
j++;
}
}
while (i < n) ops.push({ kind: "del", line: a[i++] });
while (j < m) ops.push({ kind: "add", line: b[j++] });
return ops;
}
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
let pre = 0;
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
let suf = 0;
while (
suf < a.length - pre &&
suf < b.length - pre &&
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
) {
suf++;
}
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
return [...prefixCtx, ...middle, ...suffixCtx];
}
/** Collapse a long run of unchanged lines to a summary, keeping up to
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
* shape a unified diff gives a reader, without claiming line numbers this
* module never tracked. A run touching the start/end of the whole diff has
* no change to be adjacent to on that side, so it keeps no context there
* (unless it is the *only* run, i.e. nothing changed at all). */
function hunk(ops: Op[]): DiffLine[] {
const out: DiffLine[] = [];
const push = (op: Op) => {
out.push({ kind: op.kind, text: op.line.text });
if (!op.line.terminated) {
out.push({ kind: "eof", text: "\\ No newline at end of file" });
}
};
let i = 0;
while (i < ops.length) {
if (ops[i].kind !== "ctx") {
push(ops[i]);
i++;
continue;
}
let j = i;
while (j < ops.length && ops[j].kind === "ctx") j++;
const run = ops.slice(i, j);
const wholeDiff = i === 0 && j === ops.length;
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
if (run.length <= keepHead + keepTail) {
run.forEach(push);
} else {
if (keepHead) run.slice(0, keepHead).forEach(push);
const collapsed = run.slice(keepHead, run.length - keepTail);
out.push({
kind: "hunk",
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
});
if (collapsed.some((op) => !op.line.terminated)) {
out.push({ kind: "eof", text: "\\ No newline at end of file" });
}
if (keepTail) run.slice(run.length - keepTail).forEach(push);
}
i = j;
}
return out;
}
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
* (a new file) renders every line of `newText` as added. */
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
const newLines = splitLines(newText);
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
const ops = diffLines(splitLines(oldText), newLines);
return hunk(ops);
}
/** `+added -removed` counts straight off the diff — real derived data, not a
* fabricated duration or line count. */
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const line of lines) {
if (line.kind === "add") added++;
else if (line.kind === "del") removed++;
}
return { added, removed };
}
+2 -2
View File
@@ -1,7 +1,7 @@
// Pure transcript logic for the structured pane (#246) — the decisions
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
// they can be tested without a DOM or a socket. Ported from structured.ts's
// private helpers and the parts of structuredRender.ts that were data, not
// private helpers and the parts of the old structuredRender.ts that were data, not
// markup.
import type {
@@ -14,7 +14,7 @@ import type {
TranscriptRowKind,
TurnMeta,
} from "../../types.js";
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
import { computeDiffLines, diffStats } from "./diff.js";
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
* never a blank label. */
+52
View File
@@ -0,0 +1,52 @@
// Pure text helpers for the structured transcript, kept when
// structuredRender.ts's imperative DOM builders were deleted (#253):
// markdown rendering (sanitized) and the elapsed/context formatters.
import { marked, Renderer } from "marked";
import { escapeHtml } from "../../html.js";
/** Small inline renderer retained for compact labels and focused tests. */
export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text);
return escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
}
/** Render assistant output as GFM, then sanitize the complete result before it
* enters the DOM. This gives transcripts proper tables, lists, code blocks and
* links without trusting model-authored HTML or URL attributes. */
export function renderMarkdown(text: string): string {
const renderer = new Renderer();
renderer.html = ({ text: raw }) => escapeHtml(raw);
renderer.image = ({ text: alt }) => escapeHtml(alt ? `[image: ${alt}]` : "[image]");
renderer.link = ({ href, title, tokens }) => {
const label = renderer.parser.parseInline(tokens);
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(href.trim())?.[1]?.toLowerCase();
if (scheme && !["http", "https", "mailto"].includes(scheme)) return label;
const safeTitle = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(href)}"${safeTitle}>${label}</a>`;
};
return marked.parse(text, { async: false, gfm: true, renderer });
}
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
}
export function formatContextTokens(value: number): string {
return `${compactNumber(value)} context`;
}
+1 -1
View File
@@ -92,7 +92,7 @@ export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps)
<div
role="toolbar"
aria-label="Special keys"
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
className="keybar-touch flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
>
{KEY_DEFS.map((def) => {
if ("mod" in def) {
+5 -1
View File
@@ -347,7 +347,11 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
return (
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
<div ref={bodyRef} className="min-h-0 flex-1" />
{/* `attach-body` is what styles.css keys the terminal's own sizing on
(`.attach-body .xterm { width/height: 100% }`) plus the hidden
scrollbar and touch-scroll rules. Without it the terminal measures
its own stale viewport and never grows back to the pane. */}
<div ref={bodyRef} className="attach-body min-h-0 flex-1" />
{keybarActive && (
<KeyBar
armed={keyBar.armed}