Post-React-migration regressions: spawn submit button moved to modal footer, sidebar footer restored (help/theme/palette/status/spawn), session card stripped of redundant Tailwind, action buttons use proper leaf-act/leaf-actions CSS structure for hover-only visibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
450 lines
17 KiB
TypeScript
450 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}
|
|
linkStore={linkStore}
|
|
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>
|
|
);
|
|
}
|