// 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 ( ); } 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(undefined); const [leader, setLeader] = useState(DEFAULT_LEADER); const [spawn, setSpawn] = useState(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(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).detail; if (s) openSpawn(spawnSimilarSeed(s), false); }; const onSessionSelect = (e: Event) => focusSession((e as CustomEvent).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).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(null); const viewRef = useRef(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("[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 ( <> focusSession(id)} onPreview={preview} onSpawn={openPlainSpawn} onSpawnDefaults={() => void fastForward()} onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)} onFocusPane={focusPane} /> } > layoutStore.getState().closeSession(id)} emptyState={} /> {bar.open && ( { bar.closeBar(); focusSession(id); }} onClose={bar.closeBar} /> )} {spawn && ( mountSpawned(session, { newTab: spawn.newTab })} onClose={() => setSpawn(null)} /> )} ); } async function spawnPresets(): Promise { 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 { 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 (
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.

); }