diff --git a/web/index.html b/web/index.html index 4dbd17a..b8d3f9c 100644 --- a/web/index.html +++ b/web/index.html @@ -23,8 +23,7 @@ -
- + diff --git a/web/src/App.tsx b/web/src/App.tsx index 620d6bc..1b58c27 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,448 @@ -import { ThemeProvider } from "./contexts/ThemeContext"; +// 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. +

+
+ + +
+
+ ); +} diff --git a/web/src/attach.ts b/web/src/attach.ts deleted file mode 100644 index a34deb8..0000000 --- a/web/src/attach.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { FitAddon } from "@xterm/addon-fit"; -import { WebglAddon } from "@xterm/addon-webgl"; -import { Terminal } from "@xterm/xterm"; -import "@xterm/xterm/css/xterm.css"; - -import { attachWsUrl } from "./routes.js"; -import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; -import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; -import { requestToggleDrawer } from "./drawer.js"; -import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js"; -import type { PaneConnection } from "./pane.js"; -import type { ClientControl, Uuid } from "./types.js"; -import { WebglPolicy } from "./webglRenderer.js"; - -export type { AttachInfo } from "./paneTitle.js"; - -/** One budget for the whole page — see webglRenderer.ts for why contexts are - * rationed rather than handed to every connection. */ -const webglPolicy = new WebglPolicy(); - -/** - * A single live attach: an xterm terminal bound to the daemon's attach WebSocket. - * - * The connection is **decoupled from the UI**: it owns a long-lived WS that - * keeps streaming PTY output into the terminal buffer whether or not the view - * is currently mounted. The shell moves `el` between the visible content pane - * and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never - * the connection. Only `dispose()` (eviction, an explicit disconnect, or the - * session ending) tears the socket down — so switching away and back is - * instant, with scrollback already in place. - */ -export interface AttachConnection extends PaneConnection {} - -export interface AttachOptions { - /** Invoked when the user clicks the view's explicit "disconnect" control. */ - onRequestClose?: (sessionId: Uuid) => void; - /** Invoked when the socket closes on its own — the session ended or the link - * dropped — but NOT on an intentional `dispose()` (eviction / user close). The - * shell uses this to auto-close the pane rather than leave a dead one. */ - onClosed?: (sessionId: Uuid) => void; - /** Asked on every close: should the socket be retried? The shell answers - * from the catalog — see `shouldRetryClose`. */ - shouldReconnect?: (sessionId: Uuid, code: number) => boolean; -} - -export function createAttachConnection( - sessionId: Uuid, - opts: AttachOptions = {}, - info?: AttachInfo, -): AttachConnection { - const el = document.createElement("div"); - el.className = "attach"; - // Useful identity over a bare UUID: name · command. The full id stays in the - // tooltip for when you really need it. - const titleHtml = paneTitleHtml(sessionId, info); - const titleAttr = paneTitleAttr(sessionId, info); - el.innerHTML = ` -
- - ${titleHtml} - connecting… - -
-
- `; - - const titleEl = el.querySelector(".attach-title") as HTMLSpanElement; - const body = el.querySelector(".attach-body") as HTMLDivElement; - const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; - const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; - - const keybar = buildKeyBar((data) => sendInput(data)); - el.appendChild(keybar.el); - - const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => { - stateEl.textContent = s; - stateEl.dataset.state = kind; - }; - - // Mobile-only (CSS hides it on desktop, where the sidebar is always in view): - // the session list is an off-canvas drawer and this is what opens it. - (el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () => - requestToggleDrawer(), - ); - - disconnectBtn.addEventListener("click", () => { - opts.onRequestClose?.(sessionId); - }); - - const term = new Terminal({ - cursorBlink: true, - fontFamily: - '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - fontSize: 13, - convertEol: false, - theme: { - // The terminal stays dark in both app themes (convention); colors are - // tuned to the maestro palette — signal-green cursor, slate background. - background: "#121419", - foreground: "#e6e8ec", - cursor: "#5fe3a1", - cursorAccent: "#121419", - selectionBackground: "#2b6b4f", - }, - scrollback: 5000, - allowProposedApi: true, - }); - const fit = new FitAddon(); - term.loadAddon(fit); - term.open(body); - - // GPU rendering is scoped to the mounted view, not the connection: acquired - // in onMount, released in onUnmount. See webglRenderer.ts. - let webgl: WebglAddon | null = null; - let dprMedia: MediaQueryList | null = null; - - // The glyph atlas is rasterised for one device pixel ratio. Dragging the - // window to a display with a different one (or an Android browser-zoom - // change) leaves every cell blurry until the atlas is rebuilt. - const onDprChange = () => { - try { - term.clearTextureAtlas(); - } catch { - // ignore — nothing to rebuild if the renderer already went away - } - watchDpr(); - }; - - function watchDpr(): void { - dprMedia?.removeEventListener("change", onDprChange); - dprMedia = null; - try { - dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null; - dprMedia?.addEventListener("change", onDprChange); - } catch { - // ignore — a browser without `resolution` media queries just keeps the - // atlas it has, which is the pre-WebGL behaviour anyway - } - } - - function releaseWebgl(): void { - dprMedia?.removeEventListener("change", onDprChange); - dprMedia = null; - if (!webgl) return; - const addon = webgl; - webgl = null; - try { - addon.dispose(); // xterm falls back to the DOM renderer - } catch { - // ignore — a lost context can throw on the way out - } - } - - function acquireWebgl(): void { - if (webgl || disposed || !webglPolicy.enabled()) return; - try { - const addon = new WebglAddon(); - // Context loss is routine on Android — backgrounding the tab is enough. - // Drop back to the DOM renderer, then retry only while the shared budget - // allows and this view is still the one on screen. - addon.onContextLoss(() => { - webglPolicy.noteContextLoss(); - releaseWebgl(); - if (mounted) acquireWebgl(); - }); - term.loadAddon(addon); - webgl = addon; - watchDpr(); - } catch { - // Context creation failed outright (no GPU, or the browser is already at - // its context cap). Count it like a loss so we stop asking, and stay on - // the DOM renderer. - webglPolicy.noteContextLoss(); - webgl = null; - } - } - - // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a - // line at a time and stall), so map finger drags ourselves. Two routes: - // • normal buffer → term.scrollLines walks the scrollback. - // • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize - // wheel events so xterm encodes them as mouse-wheel reports the app reads, - // scrolling *its* content (the only scroll that exists on the alt screen). - // • alt screen with no mouse tracking → nothing to do; the app owns input. - // `touch-action: none` (CSS) keeps the browser from cancelling the custom - // gesture stream; only the terminal surface opts out of native page panning. - // Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a - // later sibling of the viewport, so it paints over it and takes every touch. - { - const root = term.element as HTMLElement | undefined; - const screen = el.querySelector(".xterm-screen") as HTMLElement | null; - const cellH = () => { - const core = (term as unknown as { - _core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } }; - })._core; - return core?._renderService?.dimensions?.css?.cell?.height || 16; - }; - if (root) { - let lastY = 0; - let lastX = 0; - let remainder = 0; - root.addEventListener( - "touchstart", - (e) => { - if (e.touches.length !== 1) return; - lastY = e.touches[0].clientY; - lastX = e.touches[0].clientX; - remainder = 0; - }, - { passive: true }, - ); - root.addEventListener( - "touchmove", - (e) => { - if (e.touches.length !== 1) return; - const ch = cellH(); - const y = e.touches[0].clientY; - const px = lastY - y + remainder; // finger up → scroll toward newer - lastY = y; - const lines = Math.trunc(px / ch); - remainder = px - lines * ch; - if (lines === 0) return; - // term.modes.mouseTrackingMode is "none" unless the app turned mouse - // reporting on — that's the signal to forward instead of scrollback. - const mouseTracked = term.modes.mouseTrackingMode !== "none"; - if (mouseTracked && screen) { - // One wheel event per crossed cell, dispatched at the touch point so - // xterm computes valid cell coords for the mouse report. - const dir = lines > 0 ? 1 : -1; - for (let i = 0; i < Math.abs(lines); i++) { - screen.dispatchEvent( - new WheelEvent("wheel", { - deltaY: dir * ch, - deltaMode: 0, - clientX: lastX, - clientY: y, - bubbles: true, - cancelable: true, - }), - ); - } - e.preventDefault(); - } else if (term.buffer.active.type !== "alternate") { - term.scrollLines(lines); - e.preventDefault(); - } - }, - { passive: false }, - ); - } - } - - // Mobile: the special-key bar is only useful while the soft keyboard is up, - // so reveal it only when the terminal's hidden textarea is focused (CSS still - // gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown - // preventDefault, so the bar stays put during use. Re-fit on the toggle since - // showing/hiding the bar changes the terminal's available height. - const ta = term.textarea; - if (ta) { - ta.addEventListener("focus", () => { - keybar.el.classList.add("keybar-active"); - requestAnimationFrame(tryFit); - }); - ta.addEventListener("blur", () => { - keybar.el.classList.remove("keybar-active"); - requestAnimationFrame(tryFit); - }); - } - - let mounted = false; - // A previewed pane is visible but must not steal the keyboard from the - // sidebar, so mounting can opt out of the focus grab (and out of the one - // the socket does when it finishes connecting). - let focusOnMount = true; - // We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts - // a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`, - // so with the scrollbar hidden it falls back to a phantom 15px and the grid - // stops ~1–2 columns short of the edge. Same math, minus that subtraction. - const tryFit = () => { - if (!mounted) return; // parked views have no meaningful size to fit to - try { - const el2 = term.element; - const parent = el2?.parentElement; - const core = (term as unknown as { - _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } }; - })._core; - const cell = core?._renderService?.dimensions?.css?.cell; - if (!el2 || !parent || !cell || !cell.width || !cell.height) { - fit.fit(); // fall back if internals aren't ready yet - return; - } - // Measure the viewport's real inner width (clientWidth excludes any - // scrollbar), not the container's — so columns always fit inside the - // viewport whether or not xterm reserves a scrollbar. Falls back to the - // padded container box before the viewport exists. - const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null; - let availW: number; - let availH: number; - if (vp && vp.clientWidth > 0) { - availW = vp.clientWidth; - availH = vp.clientHeight; - } else { - const ps = getComputedStyle(parent); - const ts = getComputedStyle(el2); - availW = - parseInt(ps.width) - - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0); - availH = - parseInt(ps.height) - - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0); - } - const cols = Math.max(2, Math.floor(availW / cell.width)); - const rows = Math.max(1, Math.floor(availH / cell.height)); - if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows); - } catch { - try { - fit.fit(); - } catch { - // ignore — fit fails if the container is detached or zero-sized - } - } - }; - - let ws: WebSocket; - let reconnectMs = INITIAL_RECONNECT_MS; - let reconnectTimer: number | null = null; - // Snapshot replay starts at row zero, which can leave Codex at its oldest line. - // Follow it once per socket; later scrolling remains under the operator's control. - let awaitingReplay = true; - - const sendControl = (msg: ClientControl) => { - if (ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify(msg)); - }; - - /** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the - * mobile key bar so both flow through the same path. */ - const sendInput = (data: string) => { - if (ws.readyState !== WebSocket.OPEN) return; - ws.send(new TextEncoder().encode(data)); - }; - - const onResize = () => { - tryFit(); - sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); - }; - - function connect(): void { - awaitingReplay = true; - ws = new WebSocket(attachWsUrl(sessionId)); - ws.binaryType = "arraybuffer"; - - ws.onopen = () => { - reconnectMs = INITIAL_RECONNECT_MS; - setState("connected", "open"); - sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); - if (mounted && focusOnMount) term.focus(); - }; - - ws.onmessage = (ev) => { - if (typeof ev.data === "string") { - try { - JSON.parse(ev.data) as ClientControl; - } catch { - // ignore unknown control frames - } - return; - } - if (ev.data instanceof ArrayBuffer) { - const replay = awaitingReplay; - awaitingReplay = false; - term.write(new Uint8Array(ev.data), () => { - if (replay) term.scrollToBottom(); - }); - } else if (ev.data instanceof Blob) { - // Some browsers may default to Blob despite binaryType="arraybuffer". - void ev.data.arrayBuffer().then((b) => { - const replay = awaitingReplay; - awaitingReplay = false; - term.write(new Uint8Array(b), () => { - if (replay) term.scrollToBottom(); - }); - }); - } - }; - - ws.onerror = () => { - setState("error", "error"); - }; - - ws.onclose = (ev) => { - // The web proxy relays a daemon rejection (unknown session) as an - // application close code (4000+) carrying a human reason. Show it in the - // pane — both the header and the terminal body — and KEEP the pane up so - // the user can read why, instead of letting it flash and vanish (#39). - const reason = ev.reason?.trim(); - if (ev.code >= 4000 && reason && !disposed) { - setState(reason, "error"); - term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`); - return; - } - // An intentional dispose has already set `disposed`; eviction and user - // close handle their own pane teardown. - if (disposed) return; - - if (opts.shouldReconnect?.(sessionId, ev.code)) { - const delay = Math.min(reconnectMs, MAX_RECONNECT_MS); - setState("reconnecting…", "connecting"); - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS); - connect(); - }, delay); - return; - } - - setState(`closed (${ev.code})`, "closed"); - opts.onClosed?.(sessionId); - }; - } - - connect(); - - // PTY input: every keystroke goes out as a binary frame. A pending key-bar - // Ctrl modifier (mobile) folds into the next typed character here. - const dataDisp = term.onData((data) => { - sendInput(keybar.consume(data)); - }); - const resizeDisp = term.onResize(({ cols, rows }) => { - sendControl({ type: "Resize", cols, rows }); - }); - // xterm has no built-in copy-on-select; mirror the selection to the - // clipboard ourselves (best-effort — insecure contexts lack the API). - const selectionDisp = term.onSelectionChange(() => { - const text = term.getSelection(); - if (text) void navigator.clipboard?.writeText(text).catch(() => {}); - }); - - window.addEventListener("resize", onResize); - const visual = window.visualViewport ?? null; - visual?.addEventListener("resize", onResize); - - // FitAddon only recomputes on the events we call it from — a window resize - // misses container-driven changes (sidebar drag, mobile layout settling, - // orientation, late font load). Observe the body directly so columns always - // match the real rendered width and the terminal fills edge to edge. - const ro = new ResizeObserver(() => tryFit()); - ro.observe(body); - // Re-fit once the monospace font is actually loaded — cell width changes when - // the fallback is swapped out, which otherwise leaves stale columns. - document.fonts?.ready?.then(() => tryFit()).catch(() => {}); - - let disposed = false; - - return { - sessionId, - el, - onMount(mountOpts?: { focus?: boolean }) { - mounted = true; - focusOnMount = mountOpts?.focus !== false; - acquireWebgl(); - // Defer a tick so the element has its content-pane dimensions before fit. - requestAnimationFrame(() => { - tryFit(); - sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); - if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus(); - }); - }, - onUnmount() { - mounted = false; - // Parked views hand their GPU context back: the browser's cap is small - // (smaller still on Android) and MAX_PARKED connections outlive the pane. - releaseWebgl(); - }, - focusTerminal() { - term.focus(); - }, - // The closure above: the same bytes-out path keystrokes and the mobile key - // bar already take. - sendInput, - updateInfo(next: AttachInfo) { - titleEl.innerHTML = paneTitleHtml(sessionId, next); - titleEl.title = paneTitleAttr(sessionId, next); - }, - dispose() { - if (disposed) return; - disposed = true; - if (reconnectTimer !== null) { - window.clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ro.disconnect(); - window.removeEventListener("resize", onResize); - visual?.removeEventListener("resize", onResize); - dataDisp.dispose(); - resizeDisp.dispose(); - selectionDisp.dispose(); - releaseWebgl(); - try { - ws.close(); - } catch { - // ignore - } - term.dispose(); - el.remove(); - }, - }; -} - -/** - * One key in the mobile key bar. - * - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt). - * - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a - * pending modifier can promote it to the `\x1b[1;` form. - * - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its - * CSI parameter, whose modified form is `\x1b[;~`. - * - `char` is a literal character (the symbols the soft keyboard buries behind - * a page switch), folded through a pending modifier like typed input. - * - `seq` is a plain key (Esc, Tab) sent verbatim. - */ -type KeyDef = - | { label: string; mod: Modifier } - | { label: string; cursor: CursorFinal } - | { label: string; tilde: number } - | { label: string; char: string } - | { label: string; seq: string }; - -type Modifier = "ctrl" | "alt"; -/** CSI final bytes: arrows A–D, Home `H`, End `F`. */ -type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F"; - -/** - * The keys the mobile soft keyboard hides, ordered to flow row-major into an - * 8-column / 2-row grid (#7): - * - * Esc Tab Home ↑ End PgUp - / - * Ctrl Alt ← ↓ → PgDn | ~ - * - * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a - * real-keyboard inverted-T in the middle with Home/End filling its top corners, - * then the paging pair and the shell symbols that cost a soft-keyboard page - * switch to reach. Narrower screens scroll the bar sideways rather than - * shrinking the caps below a thumb's width. - */ -const KEY_DEFS: KeyDef[] = [ - { label: "Esc", seq: "\x1b" }, - { label: "Tab", seq: "\t" }, - { label: "Home", cursor: "H" }, - { label: "↑", cursor: "A" }, - { label: "End", cursor: "F" }, - { label: "PgUp", tilde: 5 }, - { label: "-", char: "-" }, - { label: "/", char: "/" }, - { label: "Ctrl", mod: "ctrl" }, - { label: "Alt", mod: "alt" }, - { label: "←", cursor: "D" }, - { label: "↓", cursor: "B" }, - { label: "→", cursor: "C" }, - { label: "PgDn", tilde: 6 }, - { label: "|", char: "|" }, - { label: "~", char: "~" }, -]; - -/** - * Fold a single character into its control code: letters and `@[\]^_` map to - * 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged. - */ -interface KeyBar { - readonly el: HTMLElement; - /** Fold pending modifiers into typed input (soft keyboard), consuming them. */ - consume(data: string): string; -} - -/** - * Build the Termux-style special-key bar. Hidden by default; CSS reveals it on - * coarse-pointer (touch) devices. `send` delivers bytes to the PTY. - * - * Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key — - * tapped here or typed on the soft keyboard — carries the modifier, then they - * disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and - * the keyboard stays open. - */ -function buildKeyBar(send: (data: string) => void): KeyBar { - const el = document.createElement("div"); - el.className = "attach-keybar"; - el.setAttribute("role", "toolbar"); - el.setAttribute("aria-label", "Special keys"); - - const armed: Record = { ctrl: false, alt: false }; - const modBtns: Partial> = {}; - - const setMod = (mod: Modifier, on: boolean) => { - armed[mod] = on; - modBtns[mod]?.setAttribute("aria-pressed", String(on)); - }; - const clearMods = () => { - setMod("ctrl", false); - setMod("alt", false); - }; - - for (const def of KEY_DEFS) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "keycap"; - btn.textContent = def.label; - - if ("mod" in def) { - btn.classList.add("keycap-mod"); - btn.setAttribute("aria-pressed", "false"); - btn.setAttribute("aria-label", `${def.label} modifier`); - modBtns[def.mod] = btn; - btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod])); - } else if ("cursor" in def) { - btn.addEventListener("click", () => { - send(cursorSeq(def.cursor, armed.ctrl, armed.alt)); - clearMods(); - }); - } else if ("tilde" in def) { - btn.addEventListener("click", () => { - send(tildeSeq(def.tilde, armed.ctrl, armed.alt)); - clearMods(); - }); - } else if ("char" in def) { - btn.addEventListener("click", () => { - send(applyModsToChar(def.char, armed.ctrl, armed.alt)); - clearMods(); - }); - } else { - btn.addEventListener("click", () => { - send(def.seq); - clearMods(); - }); - } - - // Keep the terminal focused (soft keyboard up) — don't let the tap steal it. - // preventDefault suppresses the native `:active` style, so flash an explicit - // press animation here for tactile feedback. - btn.addEventListener("pointerdown", (e) => { - e.preventDefault(); - btn.classList.remove("is-pressed"); - void btn.offsetWidth; // restart the animation if re-tapped mid-flash - btn.classList.add("is-pressed"); - }); - btn.addEventListener("animationend", () => btn.classList.remove("is-pressed")); - el.appendChild(btn); - } - - return { - el, - consume(data: string): string { - if ((armed.ctrl || armed.alt) && data.length === 1) { - const out = applyModsToChar(data, armed.ctrl, armed.alt); - clearMods(); - return out; - } - return data; - }, - }; -} diff --git a/web/src/barActions.test.ts b/web/src/barActions.test.ts deleted file mode 100644 index d15f8be..0000000 --- a/web/src/barActions.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// @vitest-environment happy-dom -// -// The sidebar's half of the command bar's action page (#151): the bar picks -// kill or rename and raises the flow the card already has, so what the page -// runs is what the ✕ button and the `r` key run. -import "@testing-library/jest-dom/vitest"; -import { fireEvent, screen, within } from "@testing-library/react"; -import { afterEach, beforeAll, describe, expect, it } from "vitest"; - -import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js"; -import type { CatalogListener, CatalogStore } from "./events.js"; -import type { Session } from "./types.js"; - -beforeAll(() => { - Object.defineProperty(window, "localStorage", { - value: { getItem: () => null, setItem: () => {} }, - }); -}); - -let mounted: { el: HTMLElement; dispose: () => void } | null = null; - -afterEach(() => { - screen.queryByRole("dialog")?.remove(); - mounted?.dispose(); - mounted = null; - document.body.replaceChildren(); -}); - -function session(overrides: Partial = {}): Session { - return { - id: "aaaaaaaa-0000-0000-0000-0000000000ff", - cwd: "/home/naps/tea/maestro", - command: "claude", - env: {}, - metadata: { name: "amber-falcon" }, - status: "Running", - created_at: "2026-08-24T10:00:00Z", - ...overrides, - }; -} - -function storeWith(sessions: Session[]): CatalogStore { - const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; - return { - snapshot: () => state, - subscribe: (fn: CatalogListener) => { - fn(state); - return () => {}; - }, - } as unknown as CatalogStore; -} - -function mount(s: Session): HTMLElement { - const handle = renderSidebar(storeWith([s])); - mounted = handle; - document.body.appendChild(handle.el); - return handle.el; -} - -function raise(name: string, s: Session): void { - document.dispatchEvent(new CustomEvent(name, { detail: s })); -} - -/** Let the kill chain's awaits (fetch, its json body) settle. */ -async function flush(): Promise { - for (let i = 0; i < 8; i++) await Promise.resolve(); -} - -describe("kill from the action page", () => { - it("goes through the same confirmation the sidebar raises (#160)", async () => { - const calls: string[] = []; - window.fetch = ((url: string, init?: RequestInit) => { - calls.push(`${init?.method ?? "GET"} ${url}`); - return Promise.resolve(new Response(null, { status: 204 })); - }) as typeof fetch; - const s = session(); - mount(s); - raise(KILL_SESSION_EVENT, s); - await flush(); - expect(screen.getByRole("dialog", { name: "Kill amber-falcon" })).toBeTruthy(); - expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0); - fireEvent.click(screen.getByRole("button", { name: "Kill" })); - await flush(); - expect(calls).toContain(`DELETE /api/sessions/${s.id}`); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => { - window.fetch = ((url: string) => { - if (String(url).includes("force=true")) { - return Promise.resolve(new Response(null, { status: 204 })); - } - return Promise.resolve( - new Response( - JSON.stringify({ - error: { - code: "kill_guarded", - message: "worktree has unsaved work", - unsaved_work: { untracked_files: 1 }, - }, - }), - { status: 409, headers: { "content-type": "application/json" } }, - ), - ); - }) as typeof fetch; - const s = session({ blocked_work: { untracked_files: 1 } }); - mount(s); - raise(KILL_SESSION_EVENT, s); - await flush(); - fireEvent.click(screen.getByRole("button", { name: "Kill" })); - await flush(); - const dialog = screen.getByRole("dialog", { name: "Kill amber-falcon" }); - expect(dialog.textContent).toContain("1 untracked file"); - }); -}); - -describe("rename from the action page", () => { - it("expands the folder hiding the card, since the prompt lives in the card", () => { - window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; - const s = session(); - const el = mount(s); - fireEvent.click(within(el).getByRole("button", { name: /maestro/ })); - expect(within(el).queryByRole("link", { name: /amber-falcon/ })).toBeNull(); - raise(RENAME_SESSION_EVENT, s); - expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue( - "amber-falcon", - ); - }); - - it("raises the card's own prompt, pre-filled with the session's name", () => { - window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; - const s = session(); - const el = mount(s); - raise(RENAME_SESSION_EVENT, s); - expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue( - "amber-falcon", - ); - }); -}); diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts deleted file mode 100644 index a6f33e9..0000000 --- a/web/src/catalog.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; -import "@testing-library/jest-dom/vitest"; - -import { renderSidebar } from "./catalog.js"; -import type { CatalogListener, CatalogStore } from "./events.js"; -import type { InTheWay, Session } from "./types.js"; - -// happy-dom runs without a backing store; renderSidebar reads the persisted -// sidebar width on mount. -beforeAll(() => { - Object.defineProperty(window, "localStorage", { - value: { getItem: () => null, setItem: () => {} }, - }); - // No daemon here; renderSidebar treats a failed info fetch as "absolute - // paths are a fine fallback". - window.fetch = () => Promise.reject(new Error("offline")); -}); - -afterEach(() => { - document.body.replaceChildren(); -}); - -function session(overrides: Partial): Session { - return { - id: "11111111-2222-3333-4444-555555555555", - cwd: "/home/naps/tea/maestro", - command: "claude", - env: {}, - metadata: {}, - status: "Running", - created_at: "2026-08-24T10:00:00Z", - ...overrides, - }; -} - -function storeWith(sessions: Session[]): CatalogStore { - const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; - return { - snapshot: () => state, - // Like the real store, deliver an immediate snapshot so the sidebar - // renders its rows during construction. - subscribe: (fn: CatalogListener) => { - fn(state); - return () => {}; - }, - } as unknown as CatalogStore; -} - -/** The session's card, scoped by id: rows carry no other stable, queryable - * handle (names/aria-labels routinely collide across the fixtures below), so - * this stays a raw DOM lookup. Every assertion made *within* the row it - * returns goes through `within(row)`. */ -function rowFor(el: HTMLElement, id: string): HTMLElement | null { - return el.querySelector(`[data-session-id="${id}"]`); -} - -/** Mount a sidebar and attach it to the document, the way the shell does — - * screen/within queries and `:focus` both need a connected tree. */ -function mount(store: CatalogStore): HTMLElement { - const el = renderSidebar(store).el; - document.body.appendChild(el); - return el; -} - -describe("blocked-work indicator (#83)", () => { - it("marks the row of a blocked session in diff notation", () => { - const blocked = session({ - id: "aaaaaaaa-0000-0000-0000-000000000001", - blocked_work: { - uncommitted_files: 3, - unpushed_commits: 2, - insertions: 124, - deletions: 18, - }, - }); - const el = mount(storeWith([blocked])); - const row = rowFor(el, blocked.id)!; - const badge = within(row).getByTitle(/Kill will be refused unless forced/); - expect(badge.textContent).toBe("+124-18↑2"); - // The spelled-out breakdown survives in the tooltip. - expect(badge.getAttribute("title")).toContain("3 uncommitted files, 2 unpushed commits"); - }); - - it("falls back to the file count when tracked changes carry no lines", () => { - const blocked = session({ - id: "aaaaaaaa-0000-0000-0000-00000000001a", - blocked_work: { uncommitted_files: 2, untracked_files: 1 }, - }); - const el = mount(storeWith([blocked])); - const row = rowFor(el, blocked.id)!; - const badge = within(row).getByTitle(/Kill will be refused unless forced/); - expect(badge.textContent).toBe("~2?1"); - }); - - it("does not mark clear or unprobed sessions", () => { - const clear = session({ - id: "aaaaaaaa-0000-0000-0000-000000000002", - blocked_work: {} as InTheWay, - }); - const unprobed = session({ - id: "aaaaaaaa-0000-0000-0000-000000000003", - }); - const el = mount(storeWith([clear, unprobed])); - expect( - within(rowFor(el, clear.id)!).queryByTitle(/Kill will be refused/), - ).toBeNull(); - expect( - within(rowFor(el, unprobed.id)!).queryByTitle(/Kill will be refused/), - ).toBeNull(); - }); - - it("confirms before an unguarded kill (#160)", async () => { - const calls: string[] = []; - window.fetch = ((url: string, init?: RequestInit) => { - calls.push(`${init?.method ?? "GET"} ${url}`); - return Promise.resolve(new Response(null, { status: 204 })); - }) as typeof fetch; - const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); - const el = mount(storeWith([plain])); - const row = rowFor(el, plain.id)!; - fireEvent.click(within(row).getByRole("button", { name: "Kill claude" })); - const dialog = screen.getByRole("dialog", { name: "Kill claude" }); - expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0); - fireEvent.click(within(dialog).getByRole("button", { name: "Kill" })); - await waitFor(() => { - expect(calls).toContain(`DELETE /api/sessions/${plain.id}`); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - }); - - it("kills nothing when the confirmation is cancelled (#160)", async () => { - const calls: string[] = []; - window.fetch = ((url: string, init?: RequestInit) => { - calls.push(`${init?.method ?? "GET"} ${url}`); - return Promise.resolve(new Response(null, { status: 204 })); - }) as typeof fetch; - const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" }); - const el = mount(storeWith([plain])); - const row = rowFor(el, plain.id)!; - fireEvent.click(within(row).getByRole("button", { name: "Kill claude" })); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0); - }); - - it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => { - window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch; - const blocked = session({ - id: "aaaaaaaa-0000-0000-0000-000000000008", - blocked_work: { untracked_files: 1 }, - }); - const el = mount(storeWith([blocked])); - const row = rowFor(el, blocked.id)!; - fireEvent.click(within(row).getByRole("button", { name: "Kill claude" })); - const dialog = screen.getByRole("dialog", { name: "Kill claude" }); - expect(dialog.textContent).toContain("1 untracked file"); - expect(dialog.textContent).toContain("refused unless it is forced"); - }); - - it("offers the override when the daemon refuses a guarded kill", async () => { - const calls: string[] = []; - window.fetch = ((url: string, init?: RequestInit) => { - calls.push(`${init?.method ?? "GET"} ${url}`); - if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 })); - return Promise.resolve( - new Response( - JSON.stringify({ - error: { - code: "kill_guarded", - message: "worktree has unsaved work", - unsaved_work: { untracked_files: 1 }, - }, - }), - { status: 409, headers: { "content-type": "application/json" } }, - ), - ); - }) as typeof fetch; - const blocked = session({ - id: "aaaaaaaa-0000-0000-0000-000000000004", - command: "bash", - blocked_work: { untracked_files: 1 }, - }); - const el = mount(storeWith([blocked])); - const row = rowFor(el, blocked.id)!; - fireEvent.click(within(row).getByRole("button", { name: "Kill bash" })); - fireEvent.click(screen.getByRole("button", { name: "Kill" })); - // The guarded refusal replaces the confirm dialog with the force prompt; - // wait for the swap rather than the (name-identical) dialog role alone. - const forceBtn = await screen.findByRole("button", { name: "Force kill" }); - const dialog = screen.getByRole("dialog"); - expect(dialog.textContent).toContain("1 untracked file"); - fireEvent.click(forceBtn); - await waitFor(() => { - expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - }); - - it("closes the override prompt without killing on cancel", async () => { - const calls: string[] = []; - window.fetch = ((url: string, init?: RequestInit) => { - calls.push(`${init?.method ?? "GET"} ${url}`); - return Promise.resolve( - new Response( - JSON.stringify({ - error: { code: "kill_guarded", message: "worktree has unsaved work" }, - }), - { status: 409, headers: { "content-type": "application/json" } }, - ), - ); - }) as typeof fetch; - const blocked = session({ - id: "aaaaaaaa-0000-0000-0000-000000000006", - blocked_work: { unpushed_commits: 2 }, - }); - const el = mount(storeWith([blocked])); - const row = rowFor(el, blocked.id)!; - fireEvent.click(within(row).getByRole("button", { name: "Kill claude" })); - fireEvent.click(screen.getByRole("button", { name: "Kill" })); - // Same reasoning as above: wait for the force prompt's own button before - // trusting "the" Cancel button is the one that belongs to it. - await screen.findByRole("button", { name: "Force kill" }); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0); - }); -}); - -describe("agent-reported detail (#112)", () => { - const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); - - it("renders the reported detail right after the duration on line 2", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000020", - status: "Dead", - activity: "Error", - activity_since: ago(90_000), - activity_detail: "cargo build failed", - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const line2 = row.querySelector(".card-line2")!; - expect(within(line2).getByText("cargo build failed")).toHaveClass("activity-detail"); - const order = [...line2.children].map((c) => c.className); - expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration")); - expect(order.indexOf("activity-detail")).toBeLessThan( - order.findIndex((c) => c.startsWith("card-flag")), - ); - }); - - it("omits the detail when no report is in force", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000021", - activity: "Active", - activity_since: ago(90_000), - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - // No text is rendered for a report that isn't in force, so there is - // nothing an accessible query can key off — this stays a raw DOM check. - expect(row.querySelector(".activity-detail")).toBeNull(); - }); -}); - -describe("session cards (#107)", () => { - const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); - - it("keeps the name and the actions in the same flow line, actions reserving their width", () => { - const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const line1 = row.querySelector(".card-line1")!; - expect(line1).not.toBeNull(); - // Same flex line ⇒ the name truncates before it can ever reach the buttons. - expect(within(line1).getByText("claude")).toHaveClass("leaf-name"); - expect(line1.querySelector(".leaf-actions")).toBe(line1.lastElementChild); - }); - - it("always renders line 2 for a freshly spawned session", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000011", - activity: "Active", - activity_since: ago(5_000), - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const line2 = row.querySelector(".card-line2"); - expect(line2).not.toBeNull(); - expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail"); - expect(line2!.querySelector(".activity-duration")).toBeNull(); - expect(within(row).getByTitle("claude")).toHaveClass("leaf-cmd"); - }); - - it("shows dot + floored duration once the state has been held past 30s", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000012", - activity: "Active", - activity_since: ago(4 * 60_000 + 30_000), - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const line2 = row.querySelector(".card-line2")!; - expect(line2.dataset.activity).toBe("active"); - expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail"); - expect(within(row).getByText("4m")).toHaveClass("activity-duration"); - }); - - it("dims a name the daemon generated, until it settles (#131)", () => { - const generated = session({ - id: "aaaaaaaa-0000-0000-0000-0000000000a1", - metadata: { name: "amber-falcon", name_source: "Generated" }, - }); - const haiku = session({ - id: "aaaaaaaa-0000-0000-0000-0000000000a2", - metadata: { name: "sidebar-rail", name_source: "Haiku" }, - }); - const chosen = session({ - id: "aaaaaaaa-0000-0000-0000-0000000000a3", - metadata: { name: "my-session", name_source: "User" }, - }); - // Named before `name_source` existed: settled, never dimmed. - const legacy = session({ - id: "aaaaaaaa-0000-0000-0000-0000000000a4", - metadata: { name: "older-session" }, - }); - - const el = mount(storeWith([generated, haiku, chosen, legacy])); - const nameEl = (s: Session, text: string) => within(rowFor(el, s.id)!).getByText(text); - expect(nameEl(generated, "amber-falcon")).toHaveClass("is-provisional"); - expect(nameEl(haiku, "sidebar-rail")).not.toHaveClass("is-provisional"); - expect(nameEl(chosen, "my-session")).not.toHaveClass("is-provisional"); - expect(nameEl(legacy, "older-session")).not.toHaveClass("is-provisional"); - }); - - it("renders the command as its tool icon when the tool is known", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-00000000001b", - command: "claude", - metadata: { name: "claude" }, - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const cmd = within(row).getByTitle("claude"); - expect(cmd).toHaveClass("leaf-cmd"); - expect(cmd.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); - // The icon said it all: no text left over. - expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull(); - }); - - it("drops the command's arguments, keeping them in the tooltip", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-00000000001c", - command: "claude --dangerously-skip-permissions", - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const cmd = within(row).getByTitle("claude --dangerously-skip-permissions"); - expect(cmd).toHaveClass("leaf-cmd"); - expect(cmd.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); - expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull(); - }); - - it("keeps an unknown command as plain text", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-00000000001d", - command: "hx src/main.rs", - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const cmd = within(row).getByTitle("hx src/main.rs"); - expect(cmd.querySelector(".tool-icon")).toBeNull(); - expect(within(cmd).getByText("hx")).toHaveClass("leaf-cmd-rest"); - }); - - it("maps each activity state to its contract slug", () => { - const mk = (id: string, activity: string) => - session({ - id, - activity: activity as Session["activity"], - status: "Running", - activity_since: ago(90_000), - }); - const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle"); - const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput"); - const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error"); - const el = mount(storeWith([idle, wait, error])); - // `data-activity` is a styling hook with no accessible surface of its - // own (the rail's role/name cover the state word, not the slug), so this - // stays a plain attribute assertion. - const activityOf = (s: Session) => rowFor(el, s.id)!.querySelector(".card-line2"); - expect(activityOf(idle)).toHaveAttribute("data-activity", "idle"); - expect(activityOf(wait)).toHaveAttribute("data-activity", "awaiting-input"); - expect(activityOf(error)).toHaveAttribute("data-activity", "error"); - }); - - it("treats an unknown activity variant as idle instead of failing to render", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000016", - activity: "Teleported" as Session["activity"], - activity_since: ago(120_000), - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - expect(row.querySelector(".card-line2")).toHaveAttribute("data-activity", "idle"); - expect(within(row).getByText("2m")).toHaveClass("activity-duration"); - }); - - it("moves blocked work onto line 2 as diff notation", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000017", - blocked_work: { - uncommitted_files: 3, - unpushed_commits: 2, - insertions: 124, - deletions: 18, - }, - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - expect(within(row).getByText("+124")).toBeTruthy(); - expect(within(row).getByText("-18")).toBeTruthy(); - // Line 1 carries no badges. - expect(row.querySelector(".leaf-badge")).toBeNull(); - }); - - it("shows exited on line 2 for dead sessions", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000018", - status: "Dead", - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - expect(within(row).getByText("exited")).toBeTruthy(); - }); - - it("carries the state word only in tooltips and aria labels, never as text", () => { - const s = session({ - id: "aaaaaaaa-0000-0000-0000-000000000019", - command: "claude", - metadata: { name: "worker" }, - activity: "AwaitingInput", - activity_since: ago(90_000), - }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id)!; - const rail = within(row).getByRole("img", { name: "Awaiting input" }); - expect(rail).toHaveAttribute("title", "Awaiting input"); - expect(row.textContent).not.toMatch(/awaiting.?input/i); - }); - - it("keeps the attach navigation and depth indentation", () => { - const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" }); - const el = mount(storeWith([s])); - const row = rowFor(el, s.id) as HTMLAnchorElement; - expect(row).toHaveAttribute("href", "#/attach/aaaaaaaa-0000-0000-0000-00000000001a"); - expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/); - }); -}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts deleted file mode 100644 index 485b9d9..0000000 --- a/web/src/catalog.ts +++ /dev/null @@ -1,872 +0,0 @@ -import { escapeHtml } from "./html.js"; -import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; -import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js"; -import { openContextMenu } from "./contextMenu.js"; -import { toggleHelp } from "./help.js"; -import { CommandSet, defineCommands } from "./commands.js"; -import { CatalogStore } from "./events.js"; -import type { InTheWay, Session } from "./types.js"; -import { - abbreviateCwd, - abbreviateFolderName, - blockerStats, - customSessionName, - formatInTheWay, - MAX_SESSION_NAME_LEN, - normalizeSessionName, - sessionBlockers, - isProvisionalName, - sessionName, - sessionTreePath, -} from "./types.js"; -import { splitCommand, toolIconSvg } from "./toolIcon.js"; -import { - activityColorSlug, - activityDurationLabel, - activityHeldMs, - META_LINE_MIN_HOLD_MS, -} from "./activity.js"; -import type { TreeNode } from "./tree.js"; -import { buildTree } from "./tree.js"; -import { createPaletteSelect, createThemeToggle } from "./theme.js"; -import type { DaemonLink } from "./daemonLink.js"; -import { createStatusIndicator } from "./statusIndicator.js"; - -/** Ask the shell to open the spawn modal. Handled in main.ts, where the - * multi-view controller lives; dispatched from every Spawn affordance. */ -export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; -export function requestOpenSpawn(): void { - document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); -} - -/** Ask the shell to open the command bar with an empty query. Dispatched by - * the sidebar's search field, which is the touch entry point to the bar - * (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone. - * Handled in main.ts, which owns the session snapshot the bar needs. */ -export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar"; -export function requestOpenCommandBar(): void { - document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT)); -} - -/** Ask the shell to hand the keyboard back to the focused pane's terminal — - * what Tab in the sidebar list does. Handled in main.ts, which owns the - * connection pool. */ -export const FOCUS_PANE_EVENT = "catalog-focus-pane"; - -/** Ask the shell to open the spawn modal seeded from an existing session - * ("Spawn similar"). Detail is the session being cloned. */ -export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; - -/** Ask the sidebar to kill a session — the command bar's action page picking - * `kill` (#151). Detail is the session. Handled here, so the kill goes - * through the same confirm-and-blockers flow the card's ✕ does - * (`docs/WORKTREES.md`); the bar never reimplements it. */ -export const KILL_SESSION_EVENT = "catalog-kill-session"; - -/** Ask the sidebar to rename a session — the command bar's action page picking - * `rename` (#151). Detail is the session. The prompt is the card's own inline - * field, pre-filled with the session's name (`docs/SIDEBAR.md`). */ -export const RENAME_SESSION_EVENT = "catalog-rename-session"; - -/** A session row was clicked (issue #133). The row's `href` drives normal - * hash navigation, but a split/drag can move focus without ever touching - * `location.hash` — so a click back onto a session whose hash still - * matches fires no `hashchange` and the app never hears about it. This - * event is the click-driven path that doesn't depend on the hash actually - * changing. */ -export const SESSION_SELECT_EVENT = "catalog-session-select"; - -/** The rows a keyboard walk stops on, in visual (document) order. Expanded - * folder headers are skipped: a directory is not something to focus, and the - * cards under it are one row away anyway. A *collapsed* folder still counts — - * its header is the only row it has, so skipping it would leave the folder - * unreachable. Same rule as the TUI sidebar. */ -const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]'; - -const collapsed = new Set(); - -/** Session whose card is currently being renamed inline (#125). The tree - * re-renders on every catalog delta, and an activity update arriving mid-edit - * would otherwise replace the field the user is typing into. */ -let renamingId: string | null = null; - -// docs/SIDEBAR.md: the state word is never rendered — the rail's colour -// carries it. It survives only in tooltips/aria labels so meaning is not -// colour-only. -const STATE_WORDS: Record = { - active: "Active", - idle: "Idle", - "awaiting-input": "Awaiting input", - error: "Error", -}; - -/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge, - * full height. The row's own padding (`renderNode`) shifts outward with - * nesting depth, carrying the rail with it. */ -function activityRailMarkup(slug: string, stateWord: string): string { - return ``; -} - -function metaLine(s: Session, now: number): string { - const dead = s.status === "Dead"; - const blockers = sessionBlockers(s); - const slug = activityColorSlug(s.activity ?? "Idle"); - const held = activityHeldMs(s.activity_since, now); - const duration = - held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; - - const fragments = [ - duration ? `${duration}` : "", - s.activity_detail - ? `${escapeHtml(s.activity_detail)}` - : "", - commandFragment(s.command), - blockers ? blockedFragment(blockers) : "", - dead ? `exited` : "", - ]; - return `${fragments.join("")}`; -} - -/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2` - * — with the spelled-out breakdown kept in the tooltip. */ -function blockedFragment(blockers: InTheWay): string { - const stats = blockerStats(blockers) - .map((st) => `${escapeHtml(st.text)}`) - .join(""); - const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`; - return `${stats}`; -} - -/** docs/SIDEBAR.md: the card names the program only — arguments never reach - * the card. The program becomes its tool icon when we have a mark for it, and - * stays as its bare name when we do not. The full command line, arguments and - * all, stays in the tooltip either way. */ -function commandFragment(command: string): string { - const { tool, program } = splitCommand(command); - const icon = tool ? toolIconSvg(tool) : null; - const text = icon ? "" : program; - return `${icon ?? ""}${text ? `${escapeHtml(text)}` : ""}`; -} - -function renderNode(node: TreeNode, depth: number): HTMLElement { - if (node.kind === "folder") { - const wrap = document.createElement("div"); - wrap.className = "tree-folder"; - - if (node.name) { - const header = document.createElement("button"); - header.type = "button"; - header.className = "tree-folder-header"; - header.style.paddingLeft = `${depth * 16}px`; - const isCollapsed = collapsed.has(node.path); - header.setAttribute("aria-expanded", String(!isCollapsed)); - header.dataset.navKey = `folder:${node.path}`; - // Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the - // glyph and the rotation never fight each other. - // A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading - // segments to initials (`~/g/n/maestro`) so the dir name stays readable in - // a narrow sidebar; the full path lives in the tooltip. - header.innerHTML = `▾${abbreviateFolderName(node.name)}`; - header.addEventListener("click", () => { - if (collapsed.has(node.path)) collapsed.delete(node.path); - else collapsed.add(node.path); - // re-render handled by caller observing the store; trigger a refresh: - document.dispatchEvent(new CustomEvent("catalog-rerender")); - }); - wrap.appendChild(header); - if (isCollapsed) return wrap; - } - - for (const child of node.children) { - wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth)); - } - return wrap; - } - - // The row outlives the snapshot it was built from: a refresh that changes - // nothing structural patches it in place rather than replacing it, so the - // handlers below have to read the current session, not the one captured here. - let cur = node.session; - const s = node.session; - const now = Date.now(); - const row = document.createElement("a"); - row.className = "tree-leaf session-card"; - row.href = `#/attach/${encodeURIComponent(s.id)}`; - row.style.paddingLeft = `${depth * 16 + 8}px`; - row.dataset.sessionId = s.id; - row.dataset.navKey = `session:${s.id}`; - const running = s.status === "Running"; - row.dataset.status = running ? "running" : "dead"; - const name = sessionName(s); - const slug = activityColorSlug(s.activity ?? "Idle"); - const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; - row.setAttribute( - "aria-label", - `${name}, ${running ? "running" : "exited"}, ${stateWord}`, - ); - // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. - row.innerHTML = ` - ${activityRailMarkup(slug, stateWord)} - - ${escapeHtml(name)} - - - - - - ${metaLine(s, now)} - `; - const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; - similarBtn.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })); - }); - const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; - killBtn.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - requestKill(cur, sessionName(cur)); - }); - row.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id })); - }); - row.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - openContextMenu(ev.clientX, ev.clientY, [ - { label: "Rename…", onSelect: () => beginRename(row, cur) }, - { - label: "Spawn similar", - onSelect: () => - document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })), - }, - { - label: "Kill session", - danger: true, - onSelect: () => requestKill(cur, sessionName(cur)), - }, - ]); - }); - leafPatches.set(row, (next) => { - cur = next; - const nextSlug = activityColorSlug(next.activity ?? "Idle"); - const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle; - row.setAttribute( - "aria-label", - `${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`, - ); - const rail = row.querySelector(".activity-rail"); - if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord); - const meta = row.querySelector(".card-line2"); - if (meta) meta.outerHTML = metaLine(next, Date.now()); - }); - return row; -} - -/** Refresh a standing card from a newer snapshot of its session, keeping the - * element (and so the pointer's `:hover`) exactly where it is. Registered by - * `renderNode`; only the bits `treeSignature` leaves out are rewritten. */ -const leafPatches = new WeakMap void>(); - -/** - * Everything about the tree that a patch cannot fix up. - * - * The daemon re-sends a session on every activity, foreground or cwd shift — - * a couple of times a minute per live session — and rebuilding the sidebar for - * each one drops `:hover` from the row under the pointer. Refreshes whose - * signature is unchanged are patched into the standing rows instead; anything - * that moves, renames, appears or dies changes the signature and rebuilds. - */ -function treeSignature(node: TreeNode): string { - if (node.kind === "folder") { - const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`; - if (node.name && collapsed.has(node.path)) return head; - return `${head}(${node.children.map(treeSignature).join(",")})`; - } - const s = node.session; - return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`; -} - -/** - * Kill behind a confirmation (#160): a kill is destructive and unattended - * agents live in these sessions, so the web asks first, the way the TUI always - * has. The daemon decides the rest — it refuses a kill over a worktree holding - * unsaved work, and that refusal needs a prompt the confirmation cannot give, - * a forced kill. Anything else surfaces in the dialog that asked. - */ -function requestKill(s: Session, name: string): void { - openKillConfirmDialog({ - name, - blockers: sessionBlockers(s), - onKill: async () => { - try { - await killSession(s.id); - } catch (e) { - if (e instanceof ApiRequestError && e.code === "kill_guarded") { - openForceKillDialog({ - name, - blockers: e.unsavedWork ?? sessionBlockers(s), - reason: e.detail, - onForce: () => killSession(s.id, true), - }); - return; - } - throw e; - } - }, - }); -} - -/** - * Swap the card's name for a text field (#125). Enter commits, Escape and - * blur cancel. An emptied field clears the name, so the card falls back to - * the command — the same rule the spawn form's blank name field follows. - */ -function beginRename(row: HTMLElement, s: Session): void { - const nameEl = row.querySelector(".leaf-name"); - if (!nameEl) return; - const input = document.createElement("input"); - input.type = "text"; - input.className = "leaf-name leaf-rename"; - input.value = customSessionName(s) ?? ""; - input.maxLength = MAX_SESSION_NAME_LEN; - input.setAttribute("aria-label", `Rename ${sessionName(s)}`); - // The card is an , and the tree starts a drag on pointerdown: without - // these, clicking into the field navigates away or begins dragging the row. - for (const type of ["click", "pointerdown", "dblclick"]) { - input.addEventListener(type, (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - input.focus(); - }); - } - - renamingId = s.id; - let settled = false; - const finish = () => { - if (settled) return false; - settled = true; - renamingId = null; - document.dispatchEvent(new CustomEvent("catalog-rerender")); - return true; - }; - - input.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") { - ev.preventDefault(); - // Normalised here so what is sent is what the daemon will store. - const name = normalizeSessionName(input.value); - if (!finish()) return; - void renameSession(s.id, name).catch((e: Error) => { - alert(`Failed to rename: ${e.message}`); - }); - } else if (ev.key === "Escape") { - ev.preventDefault(); - finish(); - } - }); - input.addEventListener("blur", () => finish()); - - nameEl.replaceWith(input); - input.focus(); - input.select(); -} - -// The tree has one empty state: no sessions at all. The old "No matches" one -// belonged to the inline substring filter, which the command bar replaced -// (#157) — the bar's own no-results state took over with it. -function renderEmpty(onSpawn: () => void): HTMLElement { - const el = document.createElement("div"); - el.className = "empty"; - el.innerHTML = ` - -
No sessions yet
-

Start your first session — it'll show up here.

-
`; - const spawn = document.createElement("button"); - spawn.type = "button"; - spawn.className = "btn btn-primary"; - spawn.textContent = "+ Spawn session"; - spawn.addEventListener("click", onSpawn); - (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); - return el; -} - -/** Two-tier sidebar highlight state (issue #33): which sessions are open in some - * pane (quiet), and which one is focused (loud). */ -export interface SidebarHighlights { - open: Set; - focused: string | null; -} - -/** Live handle to the persistent sidebar (built once, survives navigation). */ -export interface SidebarHandle { - /** The `