// React port of the sidebar tree (catalog.ts). The contract it renders is // docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by // repository, walked with j/k. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "zustand"; import { ApiRequestError, killSession, renameSession } from "../../api.js"; import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js"; import type { InTheWay, Session } from "../../types.js"; import { sessionBlockers, sessionName } from "../../types.js"; import { buildTree, type TreeNode } from "../../tree.js"; import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, requestToggleHelp } from "../../uiEvents.js"; import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js"; import type { DaemonLinkStoreApi, DaemonLinkState } from "../daemon/DaemonLink.js"; import { formatRelease } from "../daemon/DaemonLink.js"; import { useTheme, PALETTES, type Palette } from "../../contexts/ThemeContext.js"; import { FolderNode } from "./FolderNode.js"; import { KillDialog } from "../dialogs/KillDialog.js"; import { SessionCard } from "./SessionCard.js"; import { SidebarSearch } from "./SidebarSearch.js"; import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js"; import { useNow } from "./useNow.js"; import "./sidebar.css"; export interface SidebarProps { /** Live catalog. Either this or `sessions` supplies the tree. */ store?: CatalogStoreApi; /** Static session list — what tests and the design kit render from. */ sessions?: Session[]; /** The daemon's home dir, so cwds abbreviate to `~/…`. */ homeDir?: string; /** Showing in the focused pane. */ activeId?: string | null; /** Open in some pane, focused one included. */ openIds?: ReadonlySet; /** Previewing (docs/PREVIEW.md) — a view, never durable state. */ previewId?: string | null; onSelect?(id: string): void; /** The highlight moved onto a session, or off every session. */ onPreview?(id: string | null): void; onSpawnSimilar?(session: Session): void; onSpawn?(): void; /** Fast-forward spawn (`N`). */ onSpawnDefaults?(): void; /** Tab: hand the keyboard back to the pane. */ onFocusPane?(): void; onHelp?(): void; /** The daemon-link store, for the status indicator in the footer. */ linkStore?: DaemonLinkStoreApi; /** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */ onKill?(session: Session): void; /** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */ onRename?(id: string, name: string): void; } interface MenuState { x: number; y: number; session: Session; } interface KillState { session: Session; blockers: InTheWay | null; force: boolean; /** The daemon's refusal message, once an unforced kill has been refused. */ reason?: string; } export function Sidebar(props: SidebarProps) { const sessions = useSessions(props); const now = useNow(); const [query, setQuery] = useState(""); const [collapsed, setCollapsed] = useState>(() => new Set()); const [navKey, setNavKey] = useState(null); const [renamingId, setRenamingId] = useState(null); const [menu, setMenu] = useState(null); const [kill, setKill] = useState(null); const treeRef = useRef(null); const tree = useMemo( () => buildTree(filterSessions(sessions, query), props.homeDir), [sessions, query, props.homeDir], ); const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]); // A row that went away — session killed, folder collapsed over it — drops // the highlight rather than leaving it pointing at nothing. useEffect(() => { if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null); }, [rows, navKey]); const preview = props.onPreview; const previewOf = useCallback( (key: string | null) => { preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null); }, [preview], ); const moveNav = useCallback( (delta: number) => { if (rows.length === 0) return; const cur = rows.findIndex((r) => r.key === navKey); // Nothing highlighted: start one off the end being walked from, so the // first step lands on the first (or last) row. const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur; const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length]; setNavKey(next.key); previewOf(next.key); }, [rows, navKey, previewOf], ); const toggleFolder = useCallback((path: string) => { setCollapsed((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); /** Enter: open the highlighted session, or expand the highlighted folder and * land on the first row it was hiding. */ const activateNav = useCallback(() => { const row = rows.find((r) => r.key === navKey); if (!row) return; if (row.kind === "session") { props.onSelect?.(row.id); return; } const expanded = new Set(collapsed); expanded.delete(row.path); setCollapsed(expanded); const revealed = firstRowUnder(tree, expanded, row.path); setNavKey(revealed?.key ?? null); }, [rows, navKey, collapsed, tree, props]); const beginKill = useCallback( (s: Session) => { if (props.onKill) { props.onKill(s); return; } setKill({ session: s, blockers: sessionBlockers(s), force: false }); }, [props], ); const commitRename = useCallback( (id: string, name: string) => { setRenamingId(null); if (props.onRename) props.onRename(id, name); else void renameSession(id, name).catch(() => {}); }, [props], ); /** The session behind the highlighted row, when the highlight is on a card. */ const highlightedSession = useCallback((): Session | undefined => { const row = rows.find((r) => r.key === navKey); if (row?.kind !== "session") return undefined; return sessions.find((s) => s.id === row.id); }, [rows, navKey, sessions]); // The command bar's action page raises these rather than reimplementing // either flow (docs/COMMAND-BAR.md): a kill from the bar goes through the // same confirm-and-blockers dialog the card's ✕ does, and a rename from the // bar opens the card's own inline field. useEffect(() => { const onKillRequest = (e: Event) => { const s = (e as CustomEvent).detail; if (s) beginKill(s); }; const onRenameRequest = (e: Event) => { const s = (e as CustomEvent).detail; if (s) setRenamingId(s.id); }; document.addEventListener(KILL_SESSION_EVENT, onKillRequest); document.addEventListener(RENAME_SESSION_EVENT, onRenameRequest); return () => { document.removeEventListener(KILL_SESSION_EVENT, onKillRequest); document.removeEventListener(RENAME_SESSION_EVENT, onRenameRequest); }; }, [beginKill]); const onKeyDown = (e: React.KeyboardEvent) => { // The inline rename field owns its own keys; the walk stays out of it. if (e.target instanceof HTMLInputElement) return; switch (e.key) { case "j": case "ArrowDown": e.preventDefault(); moveNav(1); return; case "k": case "ArrowUp": e.preventDefault(); moveNav(-1); return; case "Enter": e.preventDefault(); activateNav(); return; case "Tab": e.preventDefault(); props.onFocusPane?.(); return; case "c": { const row = rows.find((r) => r.key === navKey); if (row?.kind !== "folder") return; e.preventDefault(); toggleFolder(row.path); return; } case "r": { const s = highlightedSession(); if (!s) return; e.preventDefault(); setRenamingId(s.id); return; } case "x": { const s = highlightedSession(); if (!s) return; e.preventDefault(); beginKill(s); return; } case "n": e.preventDefault(); props.onSpawn?.(); return; case "N": e.preventDefault(); props.onSpawnDefaults?.(); return; case "?": e.preventDefault(); props.onHelp?.(); return; } }; const menuItems = (s: Session): ContextMenuItem[] => [ { label: "Rename…", onSelect: () => setRenamingId(s.id) }, { label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) }, { label: "Kill session", danger: true, onSelect: () => beginKill(s) }, ]; const renderNode = (node: TreeNode, depth: number): React.ReactNode => { if (node.kind === "leaf") { const s = node.session; return ( props.onSelect?.(s.id)} onOpenMenu={(x, y) => setMenu({ x, y, session: s })} onSpawnSimilar={() => props.onSpawnSimilar?.(s)} onKill={() => beginKill(s)} onRenameCommit={(name) => commitRename(s.id, name)} onRenameCancel={() => setRenamingId(null)} /> ); } if (!node.name) { return node.children.map((c) => renderNode(c, depth)); } return ( toggleFolder(node.path)} > {node.children.map((c) => renderNode(c, depth + 1))} ); }; return ( ); } /** The tree's one empty state: no sessions at all. */ function EmptyState({ onSpawn }: { onSpawn(): void }) { return (
No sessions yet

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

); } /** Sessions from the store when one is wired, else the static prop. */ function useSessions(props: SidebarProps): Session[] { const fromStore = useStore( // `useStore` needs a store every render; the no-store case is served by a // stable empty stand-in whose result is thrown away below. props.store ?? EMPTY_STORE, (s: CatalogStore) => s.sessions, ); return useMemo( () => (props.store ? [...fromStore.values()] : (props.sessions ?? [])), [props.store, props.sessions, fromStore], ); } // One frozen state object, not a fresh one per call: `useStore` reads the // snapshot on every render and a new Map each time would never settle. const EMPTY_STATE = { sessions: new Map() }; const EMPTY_STORE = { getState: () => EMPTY_STATE, getInitialState: () => EMPTY_STATE, setState: () => {}, subscribe: () => () => {}, } as unknown as CatalogStoreApi; // --------------------------------------------------------------------------- // Sidebar footer: help, theme toggle, palette picker, status dot, spawn. // Mirrors the vanilla catalog.ts footer that the React migration dropped. // --------------------------------------------------------------------------- function SidebarFooter({ onSpawn, linkStore, }: { onSpawn(): void; linkStore?: DaemonLinkStoreApi; }) { const { theme, toggleTheme, palette, setPalette } = useTheme(); return (
{linkStore && }
); } /** Daemon status indicator: a coloured dot whose tooltip names the release. */ function StatusDot({ store }: { store: DaemonLinkStoreApi }) { const state = useStore(store, (s: DaemonLinkState) => s.state); const build = useStore(store, (s: DaemonLinkState) => s.build); const hostname = useStore(store, (s: DaemonLinkState) => s.hostname); return ( ); }