Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
39 kB · 875 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876import { 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<string, unknown>): 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<Record<string, string | undefined>>({}); 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<Set<string>>(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<Record<string, string[]>>({}); // 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<string | null>(null); const latestOrderRef = useRef<string[] | null>(null);
// The session whose rename panel is open (its pencil was clicked), + its buffer. const [editingSessionId, setEditingSessionId] = useState<string | null>(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<string, Record<string, PendingOptimism>> >({}); 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<string, PendingOptimism>, ) => Record<string, PendingOptimism>, ) => { if (!selectedNodeId) return; setPendingByNode((byNode) => ({ ...byNode, [selectedNodeId]: updater(byNode[selectedNodeId] ?? {}), })); }, [selectedNodeId], ); const reconcileSeqRef = useRef(0); const [connectError, setConnectError] = useState<string | null>(null); const [busy, setBusy] = useState(false); // The "+" button's rect while the variant picker is open (null = closed). const [menuAnchor, setMenuAnchor] = useState<DOMRect | null>(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<string, PendingOptimism> = {}; 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 ( <AnimatePresence> {sidebarOpen && session && ( <motion.div initial={{ x: "100%", opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: "100%", opacity: 0 }} transition={{ type: "spring", stiffness: 400, damping: 40 }} style={{ width }} className="fixed right-0 top-14 bottom-0 z-50 flex flex-col bg-canvas-dark border-l border-border" > {/* Left-edge drag grip: resize the pane by its leftmost edge. */} <div {...gripProps} title="Drag to resize" className={`absolute inset-y-0 left-0 w-1.5 -translate-x-1/2 z-10 cursor-col-resize transition-colors ${ dragging ? "bg-blue-500/60" : "hover:bg-blue-500/50" }`} />
{/* Header */} <div className="flex-shrink-0 px-4 py-3 border-b border-border"> <div className="flex items-center gap-3"> <AgentIcon icon={editIcon || (node?.data?.icon as string) || "cpu"} color={displayColor} /> <div className="flex-1 min-w-0"> <h2 className="text-sm font-medium text-content truncate"> {session.customName || session.agentName} </h2> <div className="flex items-center gap-2 mt-0.5"> <div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: headerStatus.color }} /> <span className="text-[10px] text-content-subtle">{headerStatus.label}</span> </div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <button onClick={() => setIsEditing(!isEditing)} className={`w-7 h-7 rounded flex items-center justify-center transition-colors ${ isEditing ? "text-content bg-surface-active" : "text-content-subtle hover:text-content hover:bg-surface-active" }`} > <Edit3 className="w-4 h-4" /> </button> <button onClick={handleClose} className="w-7 h-7 rounded flex items-center justify-center text-content-subtle hover:text-content hover:bg-surface-active transition-colors" > <X className="w-4 h-4" /> </button> </div> </div> </div>
{/* Edit Panel */} <AnimatePresence> {isEditing && ( <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="flex-shrink-0 overflow-hidden border-b border-border" > <div className="p-4 space-y-4"> <div> <label className="text-[10px] text-content-subtle uppercase tracking-wider">Name</label> <input type="text" value={editName} onChange={(e) => { 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" /> </div>
<div> <label className="text-[10px] text-content-subtle uppercase tracking-wider">Color</label> <div className="mt-2 flex flex-wrap gap-2"> {presetColors.map((color) => ( <button key={color} onClick={() => { setEditColor(color); if (selectedNodeId && session) { updateSession(selectedNodeId, { customColor: color }); if (node) { updateNode(selectedNodeId, { data: { ...node.data, color }, }); } fetch(`/api/sessions/${selectedNodeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customColor: color }), }).catch(console.error); } }} className={`w-7 h-7 rounded-md transition-all ${ editColor === color ? "ring-2 ring-content ring-offset-2 ring-offset-canvas-dark scale-110" : "hover:scale-110" }`} style={{ backgroundColor: color }} /> ))} </div> </div>
<div> <label className="text-[10px] text-content-subtle uppercase tracking-wider">Icon</label> <div className="mt-2 flex flex-wrap gap-2"> {iconOptions.map(({ id, icon: IconComponent }) => ( <button key={id} onClick={() => { setEditIcon(id); if (selectedNodeId && node) { updateNode(selectedNodeId, { data: { ...node.data, icon: id }, }); } }} className={`w-9 h-9 rounded-md transition-all flex items-center justify-center ${ editIcon === id ? "ring-2 ring-content ring-offset-2 ring-offset-canvas-dark scale-110 bg-content/10" : "hover:scale-110 hover:bg-content/5 bg-canvas" }`} style={{ borderColor: editIcon === id ? editColor : "rgb(var(--color-border-light))", borderWidth: "1px" }} > <IconComponent className="w-4 h-4" style={{ color: editIcon === id ? editColor : "rgb(var(--color-content-subtle))" }} /> </button> ))} </div> </div>
<div> <label className="text-[10px] text-content-subtle uppercase tracking-wider">Notes</label> <textarea value={editNotes} onChange={(e) => setEditNotes(e.target.value)} onBlur={() => { if (selectedNodeId && session) { fetch(`/api/sessions/${selectedNodeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes: editNotes || undefined }), }).catch(console.error); updateSession(selectedNodeId, { notes: editNotes || undefined }); } }} placeholder="Add notes..." rows={2} className="mt-1 w-full px-3 py-2 rounded-md bg-canvas border border-border text-content text-sm placeholder-content-faint focus:outline-none focus:border-border-strong transition-colors resize-none" /> </div> </div> </motion.div> )} </AnimatePresence>
{/* Session tabs — drag to reorder (order persisted per workstream). Native HTML5 DnD: the tab body is the drag surface; clicks on it still select. */} <div className="flex-shrink-0 flex items-stretch gap-1 px-2 pt-2 border-b border-border overflow-x-auto"> {orderedTabs.map((t) => { const lockedByOther = t.lock && !t.lock.mine; const dot = t.connected ? "#22C55E" : lockedByOther ? "#FBBF24" : "#6B7280"; const isActive = t.sessionId === activeTabId; const isEditingTab = editingSessionId === t.sessionId; return ( <div key={t.sessionId} draggable onDragStart={(e) => { dragIdRef.current = t.sessionId; latestOrderRef.current = null; e.dataTransfer.effectAllowed = "move"; // A payload is required for a drag to start in some browsers. e.dataTransfer.setData("text/plain", t.sessionId); }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; dragOverTab(t.sessionId); }} onDrop={(e) => { e.preventDefault(); persistOrder(); }} onDragEnd={persistOrder} className={`flex items-center gap-1.5 pl-3 pr-1.5 py-1.5 rounded-t-md text-xs whitespace-nowrap select-none cursor-grab active:cursor-grabbing transition-colors ${ isActive ? "bg-canvas text-content border-b-2 border-content" : "text-content-muted hover:bg-surface-active" }`} > <button onClick={() => { // Switching to a different tab closes an open rename pane. if (t.sessionId !== activeTabId) setEditingSessionId(null); selectTab(t.sessionId); }} title={t.sessionId} className={`flex items-center gap-1.5 min-w-0 ${isActive ? "" : "hover:text-content"}`} > <span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: dot }} /> <span className="max-w-[120px] truncate">{tabLabel(t)}</span> </button> {lockedByOther && <Lock className="w-3 h-3 text-amber-400 flex-shrink-0" />} <button onClick={(e) => { e.stopPropagation(); selectTab(t.sessionId); if (isEditingTab) { setEditingSessionId(null); } else { setEditingSessionId(t.sessionId); setEditSessionName(t.name); } }} title="Rename session" className={`flex-shrink-0 rounded p-0.5 transition-colors ${ isEditingTab ? "text-content bg-surface-active" : "text-content-faint hover:text-content" }`} > <Edit3 className="w-3 h-3" /> </button> {/* Close: ephemeral kinds only (a claude session has no doctor-based removal). */} {isEphemeralKind(t.kind) && ( <button onClick={(e) => { e.stopPropagation(); removeSession(t); }} title="Close session" className="flex-shrink-0 rounded p-0.5 text-content-faint hover:text-content transition-colors" > <X className="w-3 h-3" /> </button> )} </div> ); })} <button onClick={(e) => setMenuAnchor(e.currentTarget.getBoundingClientRect())} disabled={busy} title="Start a new session" className="flex items-center justify-center px-2 py-1.5 rounded-t-md text-content-muted hover:text-content hover:bg-surface-active transition-colors disabled:opacity-50" > <Plus className="w-3.5 h-3.5" /> </button> </div>
{/* Session rename panel (expands under the tab whose pencil was clicked) */} <AnimatePresence> {editingSessionId && ( <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="flex-shrink-0 overflow-hidden border-b border-border" > <div className="p-4"> <label className="text-[10px] text-content-subtle uppercase tracking-wider"> Session title </label> <input type="text" autoFocus value={editSessionName} onChange={(e) => { const sid = editingSessionId; if (!sid) return; const v = e.target.value; setEditSessionName(v); setPending((p) => ({ ...p, [sid]: { ov: { ...p[sid]?.ov, name: v }, seq: reconcileSeqRef.current }, })); if (selectedNodeId && v.trim()) { fetch(`/api/sessions/${selectedNodeId}/sessions/${sid}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: v }), }).catch(console.error); } }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === "Escape") setEditingSessionId(null); }} 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" /> </div> </motion.div> )} </AnimatePresence>
{/* Active session pane */} <div className="flex-1 flex flex-col min-h-0"> {!activeTab ? ( <div className="flex-1 flex items-center justify-center p-6 text-center"> <div> <p className="text-sm text-content-muted">No sessions yet</p> <button onClick={(e) => setMenuAnchor(e.currentTarget.getBoundingClientRect())} disabled={busy} className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-inverse text-inverse-content text-sm font-medium hover:bg-inverse/90 disabled:opacity-50 transition-colors" > {busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />} Start a session </button> </div> </div> ) : activeTab.connected ? ( <> <div className="flex-shrink-0 px-4 py-2 border-b border-border flex items-center justify-between"> <div className="flex items-center gap-2"> <TerminalIcon className="w-3.5 h-3.5 text-content-subtle" /> <span className="text-xs text-content-subtle">Terminal</span> </div> <button onClick={() => disconnect(activeTab)} title="Disconnect (keeps the session)" className="flex items-center gap-1.5 px-2 py-1 rounded text-[11px] text-content-muted hover:text-content hover:bg-surface-active transition-colors" > <Power className="w-3 h-3" /> Disconnect </button> </div> <div className="flex-1 min-h-0 bg-[rgb(var(--color-terminal-bg))]"> <Terminal key={activeTab.sessionId} sessionId={activeTab.sessionId} kind={activeTab.kind} color={displayColor} /> </div> </> ) : ( <div className="flex-1 flex items-center justify-center p-6"> <div className="w-full max-w-sm space-y-3 text-center"> <Plug className="w-6 h-6 text-content-subtle mx-auto" /> <div> <p className="text-sm text-content font-medium">{tabLabel(activeTab)}</p> <p className="text-xs text-content-subtle mt-0.5"> {isEphemeralKind(activeTab.kind) ? "Disconnected — connecting reopens this session fresh in the checkout (no state is resumed)." : "Disconnected — connecting resumes this Claude Code conversation (or starts it fresh if none was saved)."} </p> </div> {activeTab.lock && !activeTab.lock.mine ? ( <div className="space-y-2"> <div className="flex items-center justify-center gap-1.5 text-xs text-amber-400"> <Lock className="w-3.5 h-3.5" /> In use by {activeTab.lock.holder} </div> <button onClick={() => connect(activeTab, true)} disabled={busy} className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-md bg-amber-500 text-black text-sm font-medium hover:bg-amber-400 disabled:opacity-50 transition-colors" > {busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Power className="w-3.5 h-3.5" />} Force connect </button> </div> ) : ( <button onClick={() => connect(activeTab)} disabled={busy} className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-md bg-inverse text-inverse-content text-sm font-medium hover:bg-inverse/90 disabled:opacity-50 transition-colors" > {busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plug className="w-3.5 h-3.5" />} Connect </button> )} {isEphemeralKind(activeTab.kind) && ( <button onClick={() => removeSession(activeTab)} disabled={busy} title="Remove this session (deletes its silverwood record)" className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-md border border-border text-content-muted text-sm font-medium hover:text-content hover:bg-surface-active disabled:opacity-50 transition-colors" > <Trash2 className="w-3.5 h-3.5" /> Close session </button> )} {connectError && <p className="text-xs text-red-400">{connectError}</p>} </div> </div> )} </div>
{/* Details */} <div className="flex-shrink-0 border-t border-border"> <div className="p-4 space-y-2"> {session.notes && !isEditing && ( <p className="text-xs text-content-muted italic mb-3 pb-3 border-b border-border"> {session.notes} </p> )} <div className="flex items-center gap-2 text-xs"> <Clock className="w-3 h-3 text-content-faint flex-shrink-0" /> <span className="text-content-subtle">Started</span> <span className="text-content-muted font-mono ml-auto"> {new Date(session.createdAt).toLocaleTimeString()} </span> </div> <div className="flex items-center gap-2 text-xs"> <Folder className="w-3 h-3 text-content-faint flex-shrink-0" /> <span className="text-content-subtle">Directory</span> <span className="text-content-muted font-mono ml-auto truncate max-w-[180px]" title={session.cwd}> {session.cwd.split("/").slice(-2).join("/")} </span> </div> {session.gitBranch && ( <div className="flex items-center gap-2 text-xs"> <GitBranch className="w-3 h-3 text-content-faint flex-shrink-0" /> <span className="text-content-subtle">Branch</span> <span className="text-purple-400 font-mono ml-auto">{session.gitBranch}</span> </div> )} </div> </div>
<NewSessionMenu open={menuAnchor !== null} anchor={menuAnchor} onClose={() => setMenuAnchor(null)} onPick={startSession} /> </motion.div> )} </AnimatePresence> );}