This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/App.tsx
T
Miguel Palhas 8a05ef2eb7 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
2026-09-09 02:27:42 +01:00

449 lines
17 KiB
TypeScript

// The app shell (#253): everything main.ts used to wire by hand, as one React
// tree. The stores are the state — a catalog store fed by the events socket, a
// layout store holding the tab/split tree — and the hash route is a deep link
// into the latter.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { createSession, fetchNames, getDaemonInfo, gitRoot, projects as fetchProjects } from "./api.js";
import {
CommandSet,
DEFAULT_LEADER,
controlByte,
defineCommands,
isBarTypingTarget,
} from "./commands.js";
import { ThemeProvider } from "./contexts/ThemeContext.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, useCommandBar } from "./components/commandBar/index.js";
import { DaemonLink, createDaemonLinkStore } from "./components/daemon/DaemonLink.js";
import { RestartOverlay } from "./components/daemon/RestartOverlay.js";
import { HelpOverlay } from "./components/dialogs/index.js";
import { useViewport } from "./components/hooks/useViewport.js";
import { Drawer } from "./components/layout/Drawer.js";
import { MultiView, type MultiViewHandle } from "./components/layout/MultiView.js";
import { Sidebar } from "./components/sidebar/index.js";
import {
SpawnModal,
spawnSimilarProjectSeed,
spawnSimilarSeed,
useFastForward,
type SpawnFormProps,
} from "./components/spawn/index.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "./fastForward.js";
import { useHashRouter } from "./hooks/useHashRouter.js";
import { usePreview } from "./hooks/usePreview.js";
import { activeTab, findLeaf, openSessions } from "./layout.js";
import { createCatalogStore, type CatalogStore } from "./stores/catalogStore.js";
import { createLayoutStore, type LayoutStore } from "./stores/layoutStore.js";
import type { Project, Session, SpawnPreset, Uuid } from "./types.js";
import {
FOCUS_PANE_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
requestOpenSpawn,
requestToggleDrawer,
} from "./uiEvents.js";
type SpawnSeed = SpawnFormProps["seed"];
/** Open modal state: `seed` is what the form starts from (undefined for a
* plain spawn), `newTab` where the created session lands. */
interface SpawnState {
seed: SpawnSeed;
newTab: boolean;
}
export function App() {
return (
<ThemeProvider>
<Shell />
</ThemeProvider>
);
}
function Shell() {
useViewport();
// One instance each, for the life of the page.
const catalogStore = useMemo(() => createCatalogStore(), []);
const layoutStore = useMemo(() => createLayoutStore(), []);
const linkStore = useMemo(() => createDaemonLinkStore(), []);
const sessions = useStore(catalogStore, (s: CatalogStore) => s.sessions);
const connected = useStore(catalogStore, (s: CatalogStore) => s.connected);
const loaded = useStore(catalogStore, (s: CatalogStore) => s.loaded);
const layout = useStore(layoutStore, (s: LayoutStore) => s.layout);
const [homeDir, setHomeDir] = useState<string | undefined>(undefined);
const [leader, setLeader] = useState(DEFAULT_LEADER);
const [spawn, setSpawn] = useState<SpawnState | null>(null);
const { route, navigate } = useHashRouter();
const { preview, previewedSessionId, commit: commitPreview, end: endPreview } = usePreview({
store: layoutStore,
});
useEffect(() => {
catalogStore.getState().start();
return () => catalogStore.getState().stop();
}, [catalogStore]);
// The daemon's home dir abbreviates cwds to `~/…`; its leader rebinds the
// chords hanging off it (#159), so the `?` overlay and the command bar show
// the configured chord rather than the default. Absolute paths and the
// default leader are fine fallbacks.
useEffect(() => {
void getDaemonInfo()
.then((info) => {
setHomeDir(info.home_dir);
if (info.leader) setLeader(info.leader);
})
.catch(() => {});
}, []);
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after
// a transient lost connection has already recovered.
useEffect(() => {
const recover = () => {
if (document.visibilityState !== "visible") return;
void linkStore.getState().probe();
catalogStore.getState().reconnect();
};
document.addEventListener("visibilitychange", recover);
window.addEventListener("pageshow", recover);
window.addEventListener("online", recover);
return () => {
document.removeEventListener("visibilitychange", recover);
window.removeEventListener("pageshow", recover);
window.removeEventListener("online", recover);
};
}, [catalogStore, linkStore]);
// The catalog is the authority on which sessions exist: a pane for a session
// that is no longer listed gets closed here, rather than waiting on its own
// socket to notice. Only once a list has actually landed — before that,
// "not listed" means "not known yet" and would close every restored pane.
useEffect(() => {
if (!loaded) return;
for (const id of openSessions(layout)) {
if (!sessions.has(id)) layoutStore.getState().closeSession(id);
}
}, [loaded, sessions, layout, layoutStore]);
const tab = activeTab(layout);
const focusedSessionId =
(tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)?.sessionId) || null;
const focusedSession = focusedSessionId ? sessions.get(focusedSessionId) : undefined;
/** Open/focus a session, settling any preview of it in place. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that row silently ignored. */
const focusSession = useCallback(
(sessionId: Uuid) => {
// `commit` cancels a pending preview and opens the session; the layout
// store settles a matching preview in place rather than moving it twice.
commitPreview(sessionId);
navigate({ kind: "attach", sessionId });
},
[commitPreview, navigate],
);
// A deep link (`#/attach/:id`) opens the session it names. Only the route
// *changing* does this: focusing a pane rewrites the hash, and re-opening
// from that would fight the layout the operator just arranged.
const lastRouteRef = useRef<string | null>(null);
useEffect(() => {
const key = route.kind === "attach" ? route.sessionId : "catalog";
if (lastRouteRef.current === key) return;
lastRouteRef.current = key;
if (route.kind === "attach") layoutStore.getState().openSession(route.sessionId);
}, [route, layoutStore]);
/** Show a freshly spawned session: it gets its own pane rather than evicting
* whatever the operator was reading. */
const mountSpawned = useCallback(
(session: Session, opts?: { newTab?: boolean }) => {
layoutStore.getState().openSessionBeside(session.id, opts);
navigate({ kind: "attach", sessionId: session.id });
},
[layoutStore, navigate],
);
const openSpawn = useCallback(
(seed: SpawnSeed, newTab: boolean) => {
endPreview();
setSpawn({ seed, newTab });
},
[endPreview],
);
/** The plain spawn: the session you were last on preselects its project, so
* `n` then Enter spawns another session there without touching the picker.
* It lands in a new tab, not a split of the pane you were reading (#100). */
const openPlainSpawn = useCallback(() => {
openSpawn(focusedSession ? spawnSimilarProjectSeed(focusedSession) : undefined, true);
}, [focusedSession, openSpawn]);
/** The focused session's project — its base repo for a worktree session, its
* cwd otherwise (the rule spawnSimilarProjectSeed uses). */
const focusedProjectCwd = focusedSession
? (focusedSession.worktree?.base_repo ?? focusedSession.cwd)
: undefined;
const fastForward = useFastForward({
focusedCwd: focusedProjectCwd,
onSpawned: (session) => mountSpawned(session),
onFallback: (seed) => openSpawn(seed, false),
});
const bar = useCommandBar({ onOpen: endPreview });
useEffect(() => {
const onOpenSpawnEvent = () => openPlainSpawn();
const onSpawnSimilar = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) openSpawn(spawnSimilarSeed(s), false);
};
const onSessionSelect = (e: Event) => focusSession((e as CustomEvent<Uuid>).detail);
document.addEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
document.addEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
document.addEventListener(SESSION_SELECT_EVENT, onSessionSelect);
return () => {
document.removeEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
document.removeEventListener(SESSION_SELECT_EVENT, onSessionSelect);
};
}, [openPlainSpawn, openSpawn, focusSession]);
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
// fast-forward spawn, aimed at the chosen directory instead of the most
// recent project.
useEffect(() => {
const onSpawnDirectory = (e: Event) => {
void spawnInDirectory((e as CustomEvent<string>).detail, mountSpawned, (seed) =>
openSpawn(seed, false),
);
};
document.addEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
return () => document.removeEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
}, [mountSpawned, openSpawn]);
// Tab in the session list hands the keyboard to the pane; leader-h hands it
// back the other way. Handing it to a previewed pane is choosing it: the
// preview settles rather than snapping back when the list blurs.
const sidebarRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MultiViewHandle>(null);
const focusPane = useCallback(() => {
if (previewedSessionId) commitPreview(previewedSessionId);
viewRef.current?.focusPane();
}, [previewedSessionId, commitPreview]);
useEffect(() => {
document.addEventListener(FOCUS_PANE_EVENT, focusPane);
return () => document.removeEventListener(FOCUS_PANE_EVENT, focusPane);
}, [focusPane]);
// The global half of the command registry (#148). `n` opens the spawn modal
// and `N` fast-forwards a spawn (#67); both are plain keys, so the set's own
// `isTypingTarget` keeps them out of form fields and the terminal's hidden
// textarea. Leader-h moves the keyboard between the list and the pane — a
// chord, because an attached terminal owns every plain keystroke
// (docs/COMMAND-BAR.md).
useEffect(() => {
if (bar.open || spawn) return;
const spawnKeys = new CommandSet(
defineCommands({
"spawn.open": openPlainSpawn,
"spawn.fastForward": () => void fastForward(),
}),
);
const focusKeys = new CommandSet(
defineCommands(
{
"focus.toggle": () => {
if (sidebarRef.current?.contains(document.activeElement)) {
focusPane();
} else {
// No-op on desktop; on mobile the list is off-canvas until the
// drawer opens, so the focus has somewhere to land.
requestToggleDrawer();
sidebarRef.current?.querySelector<HTMLElement>("[tabindex]")?.focus();
}
},
// The escape hatch: binding ctrl-k takes readline's
// kill-to-end-of-line away from every pane, and leader ctrl-k gives
// it back as the literal 0x0B byte (docs/COMMAND-BAR.md).
"pane.literalCtrlK": () => viewRef.current?.sendInput("\x0b"),
// The leader pressed twice, tmux-style: one literal leader byte,
// built from the leader itself so a rebound one sends its own (#158).
"pane.literalLeader": () => viewRef.current?.sendInput(controlByte(leader)),
},
leader,
),
{ leader },
);
const onKeydown = (e: KeyboardEvent) => {
if (focusKeys.handle(e)) return;
if (isBarTypingTarget(e.target)) return;
spawnKeys.handle(e);
};
window.addEventListener("keydown", onKeydown, true);
return () => window.removeEventListener("keydown", onKeydown, true);
}, [bar.open, spawn, openPlainSpawn, fastForward, focusPane, leader]);
const openIds = useMemo(() => openSessions(layout), [layout]);
const barSessions = useMemo(() => [...sessions.values()], [sessions]);
return (
<>
<Drawer
sidebar={
<div ref={sidebarRef} className="contents">
<Sidebar
store={catalogStore}
homeDir={homeDir}
activeId={focusedSessionId}
openIds={openIds}
previewId={previewedSessionId}
onSelect={(id) => focusSession(id)}
onPreview={preview}
onSpawn={openPlainSpawn}
onSpawnDefaults={() => void fastForward()}
onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)}
onFocusPane={focusPane}
/>
</div>
}
>
<MultiView
ref={viewRef}
store={layoutStore}
// Undefined until the first list lands: MultiView's boot
// reconciliation prunes against it, and an empty map would take
// every restored pane with it.
sessions={loaded ? sessions : undefined}
onDisconnect={(id) => layoutStore.getState().closeSession(id)}
emptyState={<Welcome onSpawn={requestOpenSpawn} />}
/>
</Drawer>
<DaemonLink store={linkStore} connected={connected} />
<RestartOverlay store={linkStore} />
<HelpOverlay />
{bar.open && (
<CommandBar
sessions={barSessions}
homeDir={homeDir}
onOpenSession={(id) => {
bar.closeBar();
focusSession(id);
}}
onClose={bar.closeBar}
/>
)}
{spawn && (
<SpawnModal
seed={spawn.seed}
onSuccess={(session) => mountSpawned(session, { newTab: spawn.newTab })}
onClose={() => setSpawn(null)}
/>
)}
</>
);
}
async function spawnPresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
// keep fallbacks
}
const { FALLBACK_PRESETS } = await import("./components/spawn/model.js");
return FALLBACK_PRESETS;
}
/** A directory the project scan never saw stands in as its own project; one
* outside any repo spawns without a worktree, since there is no repo to cut a
* branch from. Anything still unresolved opens the pre-filled form. */
async function spawnInDirectory(
path: string,
onSpawned: (session: Session) => void,
onFallback: (seed: SpawnSeed) => void,
): Promise<void> {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, handle, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
known !== undefined ||
(await gitRoot(path)
.then((r) => !!r.root)
.catch(() => false));
if (!inRepo) delete req.worktree;
try {
onSpawned(await createSession(req));
return;
} catch {
// A failed POST degrades to the pre-filled form, never a dead row.
}
}
onFallback(fastForwardSeed(resolved));
}
/** The content pane with nothing open. The ☰ is mobile-only: with no pane
* there is no pane header to carry it, so the drawer would be swipe-only. */
function Welcome({ onSpawn }: { onSpawn(): void }) {
return (
<div className="empty content-welcome">
<div className="empty-glyph" aria-hidden="true">
⌘
</div>
<div className="empty-title">No session open</div>
<p className="empty-hint">
Pick a session from the list, or start a new one. Open sessions keep streaming in the
background — switching back is instant.
</p>
<p className="empty-hint">
Press <kbd>?</kbd> for keyboard shortcuts.
</p>
<div className="empty-actions">
<button
type="button"
className="btn btn-ghost welcome-sessions"
onClick={() => requestToggleDrawer()}
>
☰ Sessions
</button>
<button
type="button"
className="btn btn-primary"
title="Draft pane: n — fast-forward spawn: N"
onClick={onSpawn}
>
+ Spawn session
</button>
</div>
</div>
);
}