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 } from "./help.js"; import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; import { isCommandBarOpen, openCommandBar } from "./commandBar.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 { createPreviewSchedule } from "./previewSchedule.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 { const store = new CatalogStore(); // The daemon's home dir, for the command bar's `~/…` directory abbreviation. // Absolute paths are a fine fallback until it lands. let homeDir: string | undefined; void getDaemonInfo() .then((info) => (homeDir = info.home_dir)) .catch(() => { /* absolute paths are a fine fallback */ }); // 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; // Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst // — j held down, or the pointer sweeping the whole list — is coalesced so it // costs a couple of pane mounts instead of one per row walked. const schedulePreview = createPreviewSchedule((sessionId) => { ensureMultiView(); mv?.previewSession(sessionId); updateShellRoute(); }); const sidebar = renderSidebar(store, { onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e), link, onPreview: schedulePreview, }); 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 { // A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does // not swap the list out for a terminal on the way past a row. const hasPanes = (mv?.openSessions() ?? 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 = `
No session open

Pick a session from the list, or start a new one. Open sessions keep streaming in the background — switching back is instant.

Press ? for keyboard shortcuts.

`; 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 = `

Unknown route

#${raw.replace(/[<&]/g, "")} doesn't match anything.

Back to catalog`; 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; /** Put the multi-view in the content pane, without rendering it. A preview * renders once, on its own terms (`docs/PREVIEW.md`); a render here would * first re-mount the pane it is about to replace, and that mount takes the * keyboard away from the list being walked. */ function ensureMultiView(): void { if (!contentShowsMv) { content.replaceChildren(mv.el); contentShowsMv = true; } } function showMultiView(): void { ensureMultiView(); 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).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 { 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), }); } // The global half of the command registry (#148). Two sets, because they // differ in one thing: the help overlay owns the keyboard while it is up, so // the plain keys go deaf behind it while the focus chord does not — an // attached terminal must never be able to trap the focus keys. // // `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain // keys, so `isTypingTarget` keeps them out of form fields and the terminal's // hidden textarea. const spawnKeys = new CommandSet( defineCommands({ "spawn.open": () => requestOpenSpawn(), "spawn.fastForward": () => 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. // // This set also owns the leader (`ctrl-a`, the TUI's default): it holds the // key as a partial chord and takes select-all off it, so the escape hatch // docs/COMMAND-BAR.md describes has somewhere to land (#149). const focusKeys = new CommandSet( defineCommands({ "focus.toggle": () => { if (sidebar.hasListFocus()) { pool.focusTerminal(); } else { drawer.open(); // no-op on desktop; on mobile the list is off-canvas sidebar.focusList(); } }, }), { leader: DEFAULT_LEADER }, ); // Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command // like any other, so the `?` overlay lists it without a second list. const barKeys = new CommandSet( defineCommands({ "bar.open": () => { // Opening while a session is previewed discards the preview first, like // any other real change (docs/PREVIEW.md): the bar is not previewed into. schedulePreview.cancel(); mv?.previewSession(null); openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir }); }, }), ); // Capture phase: an attached xterm swallows every keystroke it is given, so the // bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md). window.addEventListener( "keydown", (e) => { // The bar owns the keyboard while it is up; its own input handles the keys. if (isCommandBarOpen()) return; if (focusKeys.handle(e)) return; // The help overlay owns the keyboard while it is up. if (isHelpOpen()) return; // Opening the bar over a focused terminal is #152; until then the pane's // hidden textarea keeps its Ctrl+K, like any other typing target. if (!isTypingTarget(e.target) && barKeys.handle(e)) return; spawnKeys.handle(e); }, true, ); // 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, () => { // Handing the keyboard to a previewed pane is choosing it: the preview stops // being provisional rather than snapping back the moment the list blurs. const previewed = mv.previewedSessionId(); if (previewed) focusSession(previewed); 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 { schedulePreview.cancel(); showMultiView(); mv.openSession(sessionId); updateShellRoute(); } document.addEventListener(SESSION_SELECT_EVENT, (e) => { focusSession((e as CustomEvent).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