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/main.ts
T
Miguel PalhasandClaude Sonnet 5 d4c72577b9 feat(maestro): swap web session-list/pane focus keys to Tab/Esc
Tab now moves the keyboard from the session list to the pane (was
Escape); Escape moves it back from the pane to the list, intercepted via
xterm's attachCustomKeyEventHandler so it never reaches the shell/app.
Matches the TUI's `tab`/`esc` pair; Ctrl+Q was ruled out since browsers
reserve it and never deliver it to page JS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:58:28 +01:00

437 lines
16 KiB
TypeScript

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 { renderSidebar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.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 { openSpawnModal } from "./spawnModal.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";
import { loadLayout, saveLayout } from "./persistence.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
// offline. Production only — in dev the SW would fight Vite's HMR.
function registerServiceWorker(): void {
if (import.meta.env.DEV) return;
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch(() => {
// Best-effort: a failed registration must never break the app.
});
});
}
const rootEl = document.getElementById("app");
if (!rootEl) {
throw new Error("missing #app");
}
const root: HTMLElement = rootEl;
// Dev-only: `?kit` renders the standalone design kit instead of the app shell,
// so the component system can be reviewed as a whole. Stripped from production
// builds by Vite (dynamic import behind import.meta.env.DEV).
if (import.meta.env.DEV && location.search.includes("kit")) {
void import("./kit.js").then((m) => root.replaceChildren(m.renderKit()));
} else {
void bootApp();
}
async function bootApp(): Promise<void> {
const store = new CatalogStore();
// The daemon's identity and liveness: names the release in the sidebar's status
// dot, and blocks the UI behind the restart overlay while the daemon is away
// (docs/RESTART.md). The events socket is the trigger — it dies first.
const link = new DaemonLink();
installRestartOverlay(link);
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
void link.start();
store.start();
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) {
void import("./mock.js").then((m) => m.seedMock(store));
}
// ---- persistent shell: sidebar (list) + content (welcome | attach) ----
const shellEl = document.createElement("div");
shellEl.className = "app-shell";
shellEl.dataset.route = "catalog";
// Forward-declared so the sidebar's drag hook can reach the controller (created
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
link,
});
const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
// similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
installDrawerSwipe(drawer);
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
// It can mount several panes at once — the layout tree decides how many. For now
// the tree only ever holds a single leaf (splits/tabs arrive in epic #37); this
// routes the existing single-session view through that model.
// `mv` is forward-declared above (the sidebar drag hook needs it); the pool's
// disconnect callback below also references it.
let hud: Hud | undefined;
const pool = new ConnectionPool({
onFocusChange: () => {
// Sidebar highlighting is driven by the multi-view controller; the pool only
// nudges the HUD here.
hud?.refresh();
},
// Both the explicit disconnect (⏻) and a session ending on its own close the
// pane — drop its leaf (collapsing the split) and tear down the socket. A dead
// pane is never left lying around.
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
});
// Session death used to reach the UI on two uncoordinated channels — the row
// from the events socket, the pane from `ws.onclose` — so either could happen
// without the other. The catalog is the authority: a pane for a session that is
// no longer listed gets closed here.
store.subscribe((state) => {
for (const id of mv?.openSessions() ?? []) {
if (!state.sessions.has(id)) closePane(id);
}
});
function closePane(id: string): void {
pool.close(id);
mv?.closeSession(id);
updateShellRoute();
}
// Debug HUD (#20): a fixed-corner overlay on the active session's inferred
// fields, off by default (Ctrl+Shift+D, or `?hud` in dev).
hud = mountHud(root, store, () => pool.focused());
// ---- layout state ----
// Single source of truth for what's mounted: tabs of split trees. The hash route
// is just a deep-link into it (`#/attach/:id` focuses/opens a session). Restored
// from localStorage per device (issue #34); dead sessions self-prune on attach.
const layout: Layout = loadLayout() ?? emptyLayout();
// Debounced persist — the controller calls this after every layout change.
let saveTimer: number | undefined;
function scheduleSave(): void {
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayout(layout), 400);
}
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
const hasPanes = openSessions(layout).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
function renderWelcome(): HTMLElement {
const el = document.createElement("div");
el.className = "empty content-welcome";
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌘</div>
<div class="empty-title">No session open</div>
<p class="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 class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
<div class="empty-actions"></div>`;
const actions = el.querySelector(".empty-actions") as HTMLElement;
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
// the drawer would be swipe-only. CSS hides this on desktop.
const sessions = document.createElement("button");
sessions.type = "button";
sessions.className = "btn btn-ghost welcome-sessions";
sessions.textContent = "☰ Sessions";
sessions.addEventListener("click", () => requestToggleDrawer());
actions.appendChild(sessions);
const spawn = document.createElement("button");
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());
actions.appendChild(spawn);
return el;
}
function renderNotFound(raw: string): HTMLElement {
const el = document.createElement("div");
el.className = "not-found";
el.innerHTML = `
<h1>Unknown route</h1>
<p><code>#${raw.replace(/[<&]/g, "")}</code> doesn't match anything.</p>
<a class="btn btn-primary" href="#/">Back to catalog</a>`;
return el;
}
// The multi-view controller renders tabs + splits inside the content pane and
// mounts a live attach into each session leaf.
mv = createMultiView({
pool,
layout,
sidebar,
infoFor,
onDisconnect: closePane,
renderWelcome,
// A spawn fills a pane with its new session; the shell route may flip from
// catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
function showMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
mv.render();
updateShellRoute();
}
// Spawn affordances: the sidebar's footer button, its empty state, and
// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
// The session lands in a new tab, not a split of the pane you were reading
// (#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.
// The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker.
const current = focusedSession();
openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }),
});
}
/** The session the operator was last on, if any. Shared by the plain spawn
* modal seed and fast-forward's project pick: both should land on "where I
* currently am". */
function focusedSession(): Session | undefined {
const focused = mv?.focusedSessionId() ?? null;
return focused ? store.snapshot().sessions.get(focused) : undefined;
}
/** The focused session's project — its base repo for a worktree session, its
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
function focusedProjectCwd(): string | undefined {
const s = focusedSession();
if (!s) return undefined;
return s.worktree ? s.worktree.base_repo : s.cwd;
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
});
/** Show a freshly spawned session: bring the multi-view up (the shell may still
* be on the catalog route) and give the session a pane. */
function mountSpawned(session: Session, placement?: { newTab?: boolean }): void {
showMultiView();
mv.openSessionBeside(session.id, placement);
updateShellRoute();
}
// Fast-forward spawn (#67): the spawn modal'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 the form pre-filled with
// what did resolve instead.
async function requestFastForward(): Promise<void> {
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, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
try {
const session = await createSession(req);
// Mount it, the way the TUI's fast path attaches. Without this the
// keystroke creates a session and leaves the UI untouched, so it reads
// as a dead key (#67 review).
mountSpawned(session);
return;
} catch {
// A failed POST degrades to the same pre-filled form as an
// unresolvable default — never a silent dead keystroke.
}
}
openSpawnModal({
seed: fastForwardSeed(resolved),
onSuccess: (session) => mountSpawned(session),
});
}
// Keyboard pair (#67): `n` opens the spawn modal, `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;
if (isTypingTarget(e.target)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
} else if (e.key === "N") {
e.preventDefault();
void requestFastForward();
}
});
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
window.addEventListener("keydown", (e) => {
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
if (e.key !== "H" && e.key !== "h") return;
e.preventDefault();
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
});
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. 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 session's row silently ignored. */
function focusSession(sessionId: string): void {
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
}
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
focusSession((e as CustomEvent<string>).detail);
});
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash };
}
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
case "attach":
// Deep-link: open/focus the session, then show the layout.
focusSession(route.sessionId);
return;
case "not-found":
shellEl.dataset.route = "notfound";
pool.setVisible([]);
pool.setFocus(null);
content.replaceChildren(renderNotFound(route.raw));
contentShowsMv = false;
return;
case "catalog":
// Show the current layout (welcome if empty) — navigating to #/ no longer
// tears panes down.
showMultiView();
return;
}
}
window.addEventListener("hashchange", dispatch);
dispatch();
} // bootApp