From 41b8fcba2f60c7bb1d8dee851980138b176e78fb Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Fri, 17 Jul 2026 17:29:38 -0700 Subject: [PATCH] [papyrus] Per-session tabs + Connect/resume as a stateless silverwood projection Render one tab per attached silverwood session (from 'session ls') instead of one terminal per workstream. A disconnected tab shows a Connect pane; Connect resumes that session (claude --resume ) in the checkout, gated on the advisory lock (a 409 carries the holder -> Force connect). A '+' starts a fresh session. The server runtime is now a per-session live-PTY registry ONLY (keyed by the claude session id, or a provisional uuid until the plugin hook reports it): it caches no durable data and re-reads silverwood on every GET /api/state, and the client reconciles that projection every ~1s (drag-safe, no invented state). Locks are acquired on connect / fresh-session record and released on disconnect/exit/SIGINT; status-update now records under session.workstreamId. Co-Authored-By: Claude Opus 4.8 (1M context) --- experimental/papyrus/client/src/App.tsx | 193 +++++----- .../client/src/components/NewSessionModal.tsx | 139 ++----- .../papyrus/client/src/components/Sidebar.tsx | 356 +++++++++++++----- .../client/src/components/Terminal.tsx | 1 - .../papyrus/client/src/stores/useStore.ts | 17 +- experimental/papyrus/server/index.ts | 41 +- experimental/papyrus/server/routes/api.ts | 204 +++++++--- .../papyrus/server/services/sessionManager.ts | 149 +++++--- .../papyrus/server/services/silverwood.ts | 35 ++ experimental/papyrus/server/types/index.ts | 29 +- 10 files changed, 719 insertions(+), 445 deletions(-) diff --git a/experimental/papyrus/client/src/App.tsx b/experimental/papyrus/client/src/App.tsx index fb940fa..82558ea 100644 --- a/experimental/papyrus/client/src/App.tsx +++ b/experimental/papyrus/client/src/App.tsx @@ -12,7 +12,7 @@ import { import "@xyflow/react/dist/style.css"; import { Plus } from "lucide-react"; -import { useStore } from "./stores/useStore"; +import { useStore, AgentSession } from "./stores/useStore"; import { AgentNode } from "./components/AgentNode/index"; import { Sidebar } from "./components/Sidebar"; import { NewSessionModal } from "./components/NewSessionModal"; @@ -31,21 +31,17 @@ function AppContent() { setLaunchCwd, setSelectedNodeId, setSidebarOpen, - addSession, - updateSession, agents, addAgentModalOpen, setAddAgentModalOpen, - newSessionModalOpen, - setNewSessionModalOpen, - newSessionForNodeId, - setNewSessionForNodeId, - sessions, } = useStore(); const [nodes, setNodes, onNodesChange] = useNodesState(storeNodes); const positionUpdateTimeout = useRef | null>(null); const hasRestoredRef = useRef(false); + // True while a node is being dragged, so the reconcile loop doesn't touch nodes + // (and snap a drag back). Session/tab updates still flow. + const draggingRef = useRef(false); // Sync nodes with store useEffect(() => { @@ -71,97 +67,94 @@ function AppContent() { .catch(console.error); }, [setAgents, setLaunchCwd]); - // Poll for status updates every second to catch any missed WebSocket messages + // Reconcile the client's view from silverwood every second. GET /api/state is a + // fresh projection (workstreams + per-session tabs + lock), so nodes/tabs/names + // appear, disappear, and update as the forest changes — including from other + // processes. The client invents no durable state. An existing node's position is + // never pulled from the server (positions flow client -> server on drag), so this + // never fights a drag; node mutations are also skipped while a drag is in flight. useEffect(() => { - const pollStatus = async () => { + if (agents.length === 0) return; + let cancelled = false; + + const reconcile = async () => { + let serverNodes: any[]; try { - const res = await fetch("/api/sessions"); - if (res.ok) { - const sessionsData = await res.json(); - const currentSessions = useStore.getState().sessions; - for (const sessionData of sessionsData) { - if (sessionData.nodeId && sessionData.status) { - const existing = currentSessions.get(sessionData.nodeId); - if (existing && existing.status !== sessionData.status) { - console.log(`[poll] Updating ${sessionData.nodeId} status: ${existing.status} -> ${sessionData.status}`); - updateSession(sessionData.nodeId, { status: sessionData.status }); - } - } - } - } - } catch (e) { - // Ignore errors + const res = await fetch("/api/state"); + if (!res.ok) return; + serverNodes = (await res.json()).nodes; + } catch { + return; } - }; - - // Poll immediately and then every second - pollStatus(); - const interval = setInterval(pollStatus, 1000); - return () => clearInterval(interval); - }, [updateSession]); - - // Restore sessions and categories after agents are loaded - useEffect(() => { - if (agents.length === 0 || hasRestoredRef.current) return; - - Promise.all([ - fetch("/api/sessions").then((res) => res.json()), - fetch("/api/state").then((res) => res.json()), - ]) - .then(([sessions, { nodes: savedNodes }]) => { - const restoredNodes: any[] = []; - - // Restore workstream nodes (each canvas node is a silverwood workstream) - sessions.forEach((session: any, index: number) => { - const saved = savedNodes?.find((n: any) => n.sessionId === session.sessionId); - const agent = agents.find((a) => a.id === session.agentId); - const position = saved?.position?.x - ? saved.position - : { - x: 100 + (index % 5) * 220, - y: 100 + Math.floor(index / 5) * 150, - }; - - addSession(session.nodeId, { - id: session.nodeId, - sessionId: session.sessionId, - agentId: session.agentId, - agentName: session.agentName, - command: session.command, - color: session.customColor || agent?.color || "#888", - createdAt: session.createdAt, - cwd: session.cwd, - originalCwd: session.originalCwd, - gitBranch: session.gitBranch, - status: session.status || "idle", - customName: session.customName, - customColor: session.customColor, - notes: session.notes, - isRestored: session.isRestored, - ticketId: session.ticketId, - ticketTitle: session.ticketTitle, - }); + if (cancelled || !Array.isArray(serverNodes)) return; + + const store = useStore.getState(); + const dragging = draggingRef.current; + const serverIds = new Set(); + + serverNodes.forEach((n: any, index: number) => { + serverIds.add(n.nodeId); + const agent = store.agents.find((a) => a.id === n.agentId); + const color = n.customColor || agent?.color || "#888"; + const label = n.customName || n.agentName; + const sessionData = { + id: n.nodeId, + sessionId: n.sessionId, + agentId: n.agentId, + agentName: n.agentName, + command: n.command, + color, + createdAt: n.createdAt, + cwd: n.cwd, + status: (n.status || "idle") as AgentSession["status"], + customName: n.customName, + customColor: n.customColor, + notes: n.notes, + tabs: (n.sessions || []) as AgentSession["tabs"], + }; - restoredNodes.push({ - id: session.nodeId, + if (store.sessions.has(n.nodeId)) { + store.updateSession(n.nodeId, sessionData); + if (dragging) return; + // Reflect an external rename/recolor on the canvas node label. + const node = store.nodes.find((x) => x.id === n.nodeId); + if (node && (node.data?.label !== label || node.data?.color !== color)) { + store.updateNode(n.nodeId, { data: { ...node.data, label, color } }); + } + } else if (!dragging) { + store.addSession(n.nodeId, sessionData); + const position = + n.position && typeof n.position.x === "number" + ? n.position + : { x: 100 + (index % 5) * 220, y: 100 + Math.floor(index / 5) * 150 }; + store.addNode({ + id: n.nodeId, type: "agent", position, - data: { - label: session.customName || session.agentName, - agentId: session.agentId, - color: session.customColor || agent?.color || "#888", - icon: agent?.icon || "cpu", - sessionId: session.sessionId, - }, + data: { label, agentId: n.agentId, color, icon: agent?.icon || "cpu", sessionId: n.sessionId }, }); - }); + } + }); + + // Drop workstreams that vanished (archived/deleted, possibly elsewhere). + if (!dragging) { + for (const id of [...store.sessions.keys()]) { + if (!serverIds.has(id)) { + store.removeSession(id); + store.removeNode(id); + } + } + } + hasRestoredRef.current = true; + }; - hasRestoredRef.current = true; - setNodes(restoredNodes); - setStoreNodes(restoredNodes); - }) - .catch(console.error); - }, [agents, addSession, setNodes, setStoreNodes]); + reconcile(); + const interval = setInterval(reconcile, 1000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [agents.length]); // Helper to save all positions - accepts nodes directly to avoid sync issues const saveAllPositions = useCallback((nodesToSave?: typeof nodes) => { @@ -201,6 +194,13 @@ function AppContent() { const handleNodesChange = useCallback((changes: NodeChange[]) => { onNodesChange(changes); + // Track drag state so the reconcile loop leaves nodes alone mid-drag. + for (const c of changes) { + if (c.type === "position" && "dragging" in c) { + draggingRef.current = c.dragging === true; + } + } + const positionChanges = changes.filter( (c) => c.type === "position" && "dragging" in c && c.dragging === false ); @@ -297,16 +297,7 @@ function AppContent() { - { - setAddAgentModalOpen(false); - setNewSessionModalOpen(false); - setNewSessionForNodeId(null); - }} - existingSession={newSessionForNodeId ? sessions.get(newSessionForNodeId) : undefined} - existingNodeId={newSessionForNodeId || undefined} - /> + setAddAgentModalOpen(false)} /> ); } diff --git a/experimental/papyrus/client/src/components/NewSessionModal.tsx b/experimental/papyrus/client/src/components/NewSessionModal.tsx index ce54fae..cedd7ce 100644 --- a/experimental/papyrus/client/src/components/NewSessionModal.tsx +++ b/experimental/papyrus/client/src/components/NewSessionModal.tsx @@ -3,30 +3,20 @@ import { createPortal } from "react-dom"; import { motion, AnimatePresence } from "framer-motion"; import { X, GitBranch, Loader2, AlertCircle, Sparkles } from "lucide-react"; import { useReactFlow } from "@xyflow/react"; -import { useStore, AgentSession } from "../stores/useStore"; interface NewSessionModalProps { open: boolean; onClose: () => void; - // When set, we're re-spawning the terminal for an existing workstream node. - existingSession?: AgentSession; - existingNodeId?: string; } const GRID = 24; // Create a node = create a silverwood workstream from an https git URL (silverwood -// clones its checkout), then spawn its Claude Code terminal. All durable state -// lives in silverwood; the canvas coordinate is stored in the workstream's KV. -export function NewSessionModal({ - open, - onClose, - existingSession, - existingNodeId, -}: NewSessionModalProps) { - const { addNode, addSession, updateSession } = useStore(); +// clones its checkout). All durable state lives in silverwood; the canvas +// coordinate is stored in the workstream's KV. No optimistic node — the ~1s +// reconcile adds it from the forest (server truth). +export function NewSessionModal({ open, onClose }: NewSessionModalProps) { const reactFlowInstance = useReactFlow(); - const isReplacing = !!existingNodeId && !!existingSession; const [name, setName] = useState(""); const [source, setSource] = useState(""); @@ -54,26 +44,6 @@ export function NewSessionModal({ }; }; - const handleSpawnFresh = async () => { - if (!existingNodeId || !existingSession) return; - setIsBusy(true); - setError(null); - try { - const res = await fetch(`/api/sessions/${existingSession.sessionId}/restart`, { - method: "POST", - }); - if (!res.ok) { - throw new Error((await res.json()).error || "Failed to spawn terminal"); - } - updateSession(existingNodeId, { status: "running", isRestored: false }); - onClose(); - } catch (e: any) { - setError(e.message); - } finally { - setIsBusy(false); - } - }; - const handleCreate = async () => { if (!name.trim() || !source.trim()) return; setIsBusy(true); @@ -87,32 +57,6 @@ export function NewSessionModal({ }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to create workstream"); - - const sessionId: string = data.sessionId; - addNode({ - id: sessionId, - type: "agent", - position, - data: { - label: name.trim(), - agentId: "claude", - color: "#F97316", - icon: "sparkles", - sessionId, - }, - }); - addSession(sessionId, { - id: sessionId, - sessionId, - agentId: "claude", - agentName: "Claude Code", - command: "claude", - color: "#F97316", - createdAt: new Date().toISOString(), - cwd: data.cwd || "", - status: data.checkoutState === "ready" ? "running" : "idle", - customName: name.trim(), - }); onClose(); } catch (e: any) { setError(e.message); @@ -142,9 +86,7 @@ export function NewSessionModal({
{/* Header */}
-

- {isReplacing ? "Spawn Fresh Terminal" : "New Workstream"} -

+

New Workstream

- +
- {/* Disconnected banner */} - {isDisconnected && ( -
-
-
-

Session Disconnected

-

The agent was stopped. Start a new session.

-
- -
-
- )} - - {/* Session Management Controls */} - {!isDisconnected && !isEditing && ( -
- -
- )} - {/* Edit Panel */} {isEditing && ( @@ -201,7 +292,6 @@ export function Sidebar() { onChange={(e) => { const newName = e.target.value; setEditName(newName); - // Instant update if (selectedNodeId && session) { const customName = newName !== session.agentName ? newName : undefined; updateSession(selectedNodeId, { customName }); @@ -210,8 +300,7 @@ export function Sidebar() { data: { ...node.data, label: newName }, }); } - // Persist to API - fetch(`/api/sessions/${session.sessionId}`, { + fetch(`/api/sessions/${selectedNodeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customName }), @@ -221,7 +310,7 @@ export function Sidebar() { className="mt-1 w-full px-3 py-2 rounded-md bg-canvas border border-border text-white text-sm focus:outline-none focus:border-zinc-500 transition-colors" /> - +
@@ -230,7 +319,6 @@ export function Sidebar() { key={color} onClick={() => { setEditColor(color); - // Instant update if (selectedNodeId && session) { updateSession(selectedNodeId, { customColor: color }); if (node) { @@ -238,8 +326,7 @@ export function Sidebar() { data: { ...node.data, color }, }); } - // Persist to API - fetch(`/api/sessions/${session.sessionId}`, { + fetch(`/api/sessions/${selectedNodeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customColor: color }), @@ -265,7 +352,6 @@ export function Sidebar() { key={id} onClick={() => { setEditIcon(id); - // Instant update if (selectedNodeId && node) { updateNode(selectedNodeId, { data: { ...node.data, icon: id }, @@ -277,7 +363,7 @@ export function Sidebar() { ? "ring-2 ring-white ring-offset-2 ring-offset-canvas-dark scale-110 bg-white/10" : "hover:scale-110 hover:bg-white/5 bg-canvas" }`} - style={{ borderColor: editIcon === id ? editColor : "#333", borderWidth: '1px' }} + style={{ borderColor: editIcon === id ? editColor : "#333", borderWidth: "1px" }} >
- +