import { useState, useEffect, useRef, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { X, Terminal as TerminalIcon, Clock, Folder, Edit3, Plus, Plug, Power, Lock, Loader2, Trash2, Sparkles, Code, Cpu, Zap, Rocket, Bot, Brain, Wand2, GitBranch, } from "lucide-react"; import { useStore, SessionTab } from "../stores/useStore"; import { AgentIcon } from "./AgentIcon"; import { type PendingOptimism, shouldDropOptimism, mergePendingTabs } from "./sessionOptimism"; import { orderTabs, sameOrder, moveWithinOrder } from "./tabOrder"; import { Terminal } from "./Terminal"; import { NewSessionMenu, type SessionPick } from "./NewSessionMenu"; import { useResizablePane } from "./useResizablePane"; import { workstreamStateLabel } from "../workstreamState"; // An ephemeral (non-agent) session kind — a shell or disk-space monitor. These reopen fresh // (no conversation to resume), carry no advisory lock, and are directly removable, unlike the // resumable claude-code* kinds. Mirrors the server's `kindLocks` split. const isEphemeralKind = (kind?: string) => !kind?.startsWith("claude-code"); const presetColors = [ "#F97316", "#22C55E", "#3B82F6", "#8B5CF6", "#EC4899", "#EF4444", "#FBBF24", "#14B8A6" ]; const iconOptions = [ { id: "sparkles", icon: Sparkles, label: "Sparkles" }, { id: "code", icon: Code, label: "Code" }, { id: "cpu", icon: Cpu, label: "CPU" }, { id: "zap", icon: Zap, label: "Zap" }, { id: "rocket", icon: Rocket, label: "Rocket" }, { id: "bot", icon: Bot, label: "Bot" }, { id: "brain", icon: Brain, label: "Brain" }, { id: "wand2", icon: Wand2, label: "Wand" }, ]; // The tab title is silverwood's session `name`, verbatim. function tabLabel(t: SessionTab): string { return t.name; } // Fire-and-forget PATCH of a workstream's papyrus KV (tab state). Best-effort: the tab // order / last-used tab are non-critical UI state that self-heals on the next selection, // reorder, or reconcile, so a failed write is swallowed rather than surfaced. Both the // async rejection and the sync no-origin throw (a relative URL under about:blank, e.g. // in tests) are ignored. function patchWorkstream(id: string, body: Record): void { try { fetch(`/api/sessions/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }).catch(() => {}); } catch { // No usable origin to persist to (e.g. the test DOM) — nothing to do. } } export function Sidebar() { const { sidebarOpen, setSidebarOpen, selectedNodeId, sessions, setSelectedNodeId, updateSession, updateNode, nodes, } = useStore(); const session = selectedNodeId ? sessions.get(selectedNodeId) : null; const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : null; const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(""); const [editNotes, setEditNotes] = useState(""); const [editColor, setEditColor] = useState(""); const [editIcon, setEditIcon] = useState(""); // Drag-resizable pane width (persisted). Lives outside the AnimatePresence // subtree so it survives the pane closing/reopening. const { width, dragging, gripProps } = useResizablePane({ storageKey: "papyrus:sidebarWidth", defaultWidth: 512, min: 360, max: 1200, }); // Selected tab, scoped per workstream (node id → session id) and persisted to papyrus // KV so reopening a workstream restores its last-used tab. A single shared value would // (and did) reset to the first tab whenever you switched workstreams. const [activeTabByNode, setActiveTabByNode] = useState>({}); const activeTabId = selectedNodeId ? activeTabByNode[selectedNodeId] : undefined; // Set this workstream's selected tab locally (no persist) — for fallbacks/clamping. const setActiveForNode = useCallback( (id: string | undefined) => { if (!selectedNodeId) return; setActiveTabByNode((m) => ({ ...m, [selectedNodeId]: id })); }, [selectedNodeId], ); // Select a tab from a user action: update locally AND persist to KV. const selectTab = useCallback( (id: string) => { if (!selectedNodeId) return; setActiveForNode(id); patchWorkstream(selectedNodeId, { activeTab: id }); }, [selectedNodeId, setActiveForNode], ); // Which workstreams we've already seeded from their persisted `activeTab` this session, // so seeding runs once per node and a later local selection is never overwritten. const seededRef = useRef>(new Set()); // Drag-reorder optimism (node id → ordered session ids), mirroring `pendingByNode`. // Shown immediately on drag; retired once the server echoes the same order. const [orderByNode, setOrderByNode] = useState>({}); // The tab being dragged (HTML5 DnD), and the last order produced this drag (null until // an actual reorder happens, so a click-drag with no movement persists nothing). const dragIdRef = useRef(null); const latestOrderRef = useRef(null); // The session whose rename panel is open (its pencil was clicked), + its buffer. const [editingSessionId, setEditingSessionId] = useState(null); const [editSessionName, setEditSessionName] = useState(""); // Optimism: an action's server-confirmed result, shown until the ~1s reconcile // reflects it (mount-from-response). Each override is stamped with the reconcile // generation it was set at (so a `connected` optimism can be retired even if the // server never echoes it — the fast-fail race). **Scoped per workstream** (node id // → sessionId → override): an override only applies to the workstream it was made // in, so switching workstreams can never surface a phantom tab in another one. const [pendingByNode, setPendingByNode] = useState< Record> >({}); const pending = selectedNodeId ? (pendingByNode[selectedNodeId] ?? {}) : {}; // Update the selected workstream's optimism bucket (no-op with nothing selected). // Keeps the historic `setPending((prev) => next)` shape at every call site. const setPending = useCallback( ( updater: ( prev: Record, ) => Record, ) => { if (!selectedNodeId) return; setPendingByNode((byNode) => ({ ...byNode, [selectedNodeId]: updater(byNode[selectedNodeId] ?? {}), })); }, [selectedNodeId], ); const reconcileSeqRef = useRef(0); const [connectError, setConnectError] = useState(null); const [busy, setBusy] = useState(false); // The "+" button's rect while the variant picker is open (null = closed). const [menuAnchor, setMenuAnchor] = useState(null); const storeTabs: SessionTab[] = session?.tabs ?? []; // The tab list: the server projection, with THIS workstream's optimism merged over // it (`pending` is already scoped to the selected node — see `pendingByNode`). const tabs: SessionTab[] = mergePendingTabs(storeTabs, pending); // Apply the user's tab order: a live drag's optimistic order takes precedence, else the // persisted order from KV. Tabs not in the order (e.g. a just-created one) land at the end. const effectiveOrder = (selectedNodeId ? orderByNode[selectedNodeId] : undefined) ?? session?.tabOrder; const orderedTabs: SessionTab[] = orderTabs(tabs, effectiveOrder); // Live-reorder while dragging over a tab: move the dragged tab into the hovered tab's // slot and show it immediately (optimism only). Computed from the current (already // optimistic) order, so hovering across neighbors shifts smoothly. const dragOverTab = (overId: string) => { const dragId = dragIdRef.current; if (!dragId || !selectedNodeId || dragId === overId) return; const currentIds = orderedTabs.map((t) => t.sessionId); const next = moveWithinOrder(currentIds, dragId, overId); if (sameOrder(next, currentIds)) return; latestOrderRef.current = next; setOrderByNode((m) => ({ ...m, [selectedNodeId]: next })); }; // Persist the settled order once the drag ends — only if a reorder actually happened. const persistOrder = () => { const ids = latestOrderRef.current; dragIdRef.current = null; if (!selectedNodeId || !ids) return; patchWorkstream(selectedNodeId, { tabOrder: ids }); }; // Reset node-level edit buffers when the selected workstream changes. useEffect(() => { if (session) { setEditName(session.customName || session.agentName); setEditNotes(session.notes || ""); setEditColor(session.customColor || session.color); const nodeIcon = node?.data?.icon; setEditIcon(typeof nodeIcon === "string" ? nodeIcon : "cpu"); } setIsEditing(false); setEditingSessionId(null); setConnectError(null); }, [selectedNodeId]); // Seed a workstream's selected tab from its persisted `activeTab` (papyrus KV), once // per node per session. Waits until the value is projected; a manual selection made // first wins (the `m[id] !== undefined` guard); an invalid/deleted id is corrected by // the clamp below. This is what restores the last-used tab when a workstream reopens. useEffect(() => { const id = selectedNodeId; if (!id || seededRef.current.has(id)) return; const persisted = session?.activeTab; if (!persisted) return; seededRef.current.add(id); setActiveTabByNode((m) => (m[id] !== undefined ? m : { ...m, [id]: persisted })); }, [selectedNodeId, session?.activeTab]); // Default/clamp the selected tab to one that exists, scoped to this workstream. Uses a // functional update (not the stale render-closure `activeTabId`) so it composes with the // seed effect in the same commit: if the seed just restored a valid tab from KV, this // no-ops instead of clobbering it back to the first tab. The fallback to the first tab is // local-only — only explicit user selection persists, so a stale id never overwrites KV. useEffect(() => { if (!selectedNodeId) return; const id = selectedNodeId; if (orderedTabs.length === 0) { setActiveTabByNode((m) => (m[id] === undefined ? m : { ...m, [id]: undefined })); return; } setActiveTabByNode((m) => { const cur = m[id]; if (cur && orderedTabs.some((t) => t.sessionId === cur)) return m; return { ...m, [id]: orderedTabs[0].sessionId }; }); }, [orderedTabs, selectedNodeId]); // Retire a drag's optimistic order once the server echoes the same order back. useEffect(() => { if (!selectedNodeId) return; const override = orderByNode[selectedNodeId]; if (override && sameOrder(session?.tabOrder, override)) { setOrderByNode((m) => { const n = { ...m }; delete n[selectedNodeId]; return n; }); } }, [session?.tabOrder, selectedNodeId, orderByNode]); // Close the rename panel if its session's tab vanished. useEffect(() => { if (editingSessionId && !tabs.some((t) => t.sessionId === editingSessionId)) { setEditingSessionId(null); } }, [tabs, editingSessionId]); // Each reconcile bumps the generation counter; drop optimism once the server agrees, or // (for a `connected` optimism) once ≥2 reconciles have elapsed since it was set — so a // resume that fails before the server ever reports `connected:true` still unsticks the tab. useEffect(() => { reconcileSeqRef.current += 1; setPending((p) => { const keys = Object.keys(p); if (keys.length === 0) return p; const seqNow = reconcileSeqRef.current; let changed = false; const next: Record = {}; for (const sid of keys) { const t = storeTabs.find((x) => x.sessionId === sid); if (shouldDropOptimism(p[sid], t, seqNow)) changed = true; else next[sid] = p[sid]; } return changed ? next : p; }); }, [storeTabs]); const activeTab = orderedTabs.find((t) => t.sessionId === activeTabId); const handleClose = () => { setSidebarOpen(false); setSelectedNodeId(null); setIsEditing(false); }; const connect = async (tab: SessionTab, force = false) => { if (!selectedNodeId) return; setBusy(true); setConnectError(null); selectTab(tab.sessionId); try { const res = await fetch(`/api/sessions/${selectedNodeId}/sessions/connect`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: tab.sessionId, force }), }); const data = await res.json().catch(() => ({})); if (res.status === 409) { setConnectError(data.holder ? `In use by ${data.holder}` : data.error || "Locked"); return; } if (!res.ok) { setConnectError(data.error || "Failed to connect"); return; } setPending((p) => ({ ...p, [data.sessionId]: { ov: { connected: true }, seq: reconcileSeqRef.current }, })); selectTab(data.sessionId); } catch (e: any) { setConnectError(e.message); } finally { setBusy(false); } }; // Start a fresh session of the picked kind (+ its option values) in this workstream. The // server records the durable silverwood session, then returns the id + the kind/name it // used, so the optimistic tab is correct for every kind (not just shell vs claude). const startSession = async (pick: SessionPick) => { setMenuAnchor(null); if (!selectedNodeId) return; setBusy(true); setConnectError(null); try { const res = await fetch(`/api/sessions/${selectedNodeId}/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind: pick.kind, options: pick.options }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setConnectError(data.error || "Failed to start session"); return; } setPending((p) => ({ ...p, [data.sessionId]: { ov: { connected: true, kind: data.kind, name: data.name }, seq: reconcileSeqRef.current, }, })); selectTab(data.sessionId); } catch (e: any) { setConnectError(e.message); } finally { setBusy(false); } }; const disconnect = async (tab: SessionTab) => { if (!selectedNodeId) return; setPending((p) => { const n = { ...p }; delete n[tab.sessionId]; return n; }); try { await fetch(`/api/sessions/${selectedNodeId}/sessions/${tab.sessionId}/disconnect`, { method: "POST", }); } catch { // The reconcile loop will reflect the real state. } }; // Remove a session for good: kill any live PTY and delete its silverwood record. // This is how a plain shell is closed (doctor can't retire a conversation-less // shell). The tab vanishes on the next reconcile; drop its optimism now. const removeSession = async (tab: SessionTab) => { if (!selectedNodeId) return; setPending((p) => { const n = { ...p }; delete n[tab.sessionId]; return n; }); if (editingSessionId === tab.sessionId) setEditingSessionId(null); try { await fetch(`/api/sessions/${selectedNodeId}/sessions/${tab.sessionId}`, { method: "DELETE", }); } catch { // The reconcile loop will reflect the real state. } }; const displayColor = editColor || session?.customColor || session?.color || "#888"; // Workstream-level indicator: silverwood's algebraic state, plus the N/M-connected // agent count for a Ready Basic workstream. const headerStatus = workstreamStateLabel({ overallState: session?.overallState, kind: session?.kind, checkoutState: session?.checkoutState, connected: session?.connected ?? false, tabs, }); return ( {sidebarOpen && session && ( {/* Left-edge drag grip: resize the pane by its leftmost edge. */}
{/* Header */}

{session.customName || session.agentName}

{headerStatus.label}
{/* Edit Panel */} {isEditing && (
{ const newName = e.target.value; setEditName(newName); if (selectedNodeId && session) { const customName = newName !== session.agentName ? newName : undefined; updateSession(selectedNodeId, { customName }); if (node) { updateNode(selectedNodeId, { data: { ...node.data, label: newName }, }); } fetch(`/api/sessions/${selectedNodeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customName }), }).catch(console.error); } }} className="mt-1 w-full px-3 py-2 rounded-md bg-canvas border border-border text-content text-sm focus:outline-none focus:border-border-strong transition-colors" />
{presetColors.map((color) => (
{iconOptions.map(({ id, icon: IconComponent }) => ( ))}