diff --git a/src/components/monitor/ActionGraph.tsx b/src/components/monitor/ActionGraph.tsx index 5b4cd25..b449291 100644 --- a/src/components/monitor/ActionGraph.tsx +++ b/src/components/monitor/ActionGraph.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' import { ReactFlow, Background, @@ -15,7 +15,7 @@ import { MarkerType, ReactFlowProvider, } from '@xyflow/react' -import { LayoutGrid, ArrowRightLeft, ArrowUpDown, Crosshair } from 'lucide-react' +import { LayoutGrid, ArrowRightLeft, ArrowUpDown, Crosshair, Loader2 } from 'lucide-react' import '@xyflow/react/dist/style.css' import { SessionNode } from './SessionNode' import { ActionNode } from './ActionNode' @@ -35,6 +35,7 @@ interface ActionGraphProps { execs: MonitorExecProcess[] selectedSession: string | null onSessionSelect: (key: string | null) => void + isHydrating?: boolean } /** Cast domain data to ReactFlow's Node data type */ @@ -82,6 +83,7 @@ function ActionGraphInner({ execs, selectedSession, onSessionSelect, + isHydrating = false, }: ActionGraphProps) { // Crab AI state const crabRef = useRef({ @@ -106,6 +108,10 @@ function ActionGraphInner({ const [followMode, setFollowMode] = useState(false) const isAnimatingRef = useRef(false) + // Transition for large graph layout calculations + const [isPending, startTransition] = useTransition() + const [asyncLayoutResult, setAsyncLayoutResult] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null) + // Get ReactFlow instance for viewport control const { setCenter } = useReactFlow() @@ -358,23 +364,52 @@ function ActionGraphInner({ return edges }, [sessions, visibleActions, visibleExecs, selectedSession]) - // Apply layout - const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { - if (rawNodes.length === 1) { - return { - nodes: [{ ...rawNodes[0]!, position: { x: 0, y: 0 } }], - edges: [], + // Apply layout - fast path for small graphs, transition for large graphs + const LARGE_GRAPH_THRESHOLD = 100 + + // Fast path for small graphs (synchronous) + const immediateLayout = useMemo(() => { + if (rawNodes.length < LARGE_GRAPH_THRESHOLD) { + if (rawNodes.length === 1) { + return { + nodes: [{ ...rawNodes[0]!, position: { x: 0, y: 0 } }], + edges: [], + } } + return layoutGraph(rawNodes, rawEdges, { + direction: layoutDirection, + nodeWidth: 200, + nodeHeight: 80, + rankSep: 60, + nodeSep: 30, + }) } - return layoutGraph(rawNodes, rawEdges, { - direction: layoutDirection, - nodeWidth: 200, - nodeHeight: 80, - rankSep: 60, - nodeSep: 30, - }) + return null }, [rawNodes, rawEdges, layoutDirection]) + // Async path for large graphs (uses transition to keep UI responsive) + useEffect(() => { + if (rawNodes.length >= LARGE_GRAPH_THRESHOLD) { + startTransition(() => { + const result = layoutGraph(rawNodes, rawEdges, { + direction: layoutDirection, + nodeWidth: 200, + nodeHeight: 80, + rankSep: 60, + nodeSep: 30, + }) + setAsyncLayoutResult(result) + }) + } else { + // Clear async result when switching to small graph + setAsyncLayoutResult(null) + } + }, [rawNodes, rawEdges, layoutDirection]) + + // Use whichever result is available + const layoutedNodes = immediateLayout?.nodes ?? asyncLayoutResult?.nodes ?? [] + const layoutedEdges = immediateLayout?.edges ?? asyncLayoutResult?.edges ?? [] + // Initial nodes with chaser (click handler added later) const initialNodes = useMemo(() => { const crab = crabRef.current @@ -817,6 +852,26 @@ function ActionGraphInner({ + {/* Loading overlay for layout calculation */} + {isPending && ( +
+
+ + + Calculating layout for {rawNodes.length.toLocaleString()} nodes... + +
+
+ )} + {/* Loading overlay for initial hydration */} + {isHydrating && layoutedNodes.length === 0 && ( +
+
+ + Loading graph data... +
+
+ )} { if (node.type === 'crab') return '#ef4444' diff --git a/src/routes/monitor/index.tsx b/src/routes/monitor/index.tsx index 88cb487..3395c91 100644 --- a/src/routes/monitor/index.tsx +++ b/src/routes/monitor/index.tsx @@ -83,6 +83,9 @@ function MonitorPage() { // Settings panel state const [settingsOpen, setSettingsOpen] = useState(false) + // Hydrating state for large graph loading + const [isHydrating, setIsHydrating] = useState(false) + // Live queries from TanStack DB collections const sessionsQuery = useLiveQuery(sessionsCollection) const actionsQuery = useLiveQuery(actionsCollection) @@ -160,17 +163,20 @@ function MonitorPage() { try { const status = await trpc.clawdbot.persistenceStatus.query() if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) { + setIsHydrating(true) const data = await trpc.clawdbot.persistenceHydrate.query() hydrateFromServer(data.sessions, data.actions, data.execEvents ?? []) console.log( `[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions, ${(data.execEvents ?? []).length} exec events` ) + setIsHydrating(false) } setPersistenceEnabled(status.enabled) setPersistenceStartedAt(status.startedAt) setPersistenceSessionCount(status.sessionCount) setPersistenceActionCount(status.actionCount) } catch (e) { + setIsHydrating(false) console.error('Failed to hydrate:', e) } } @@ -499,6 +505,7 @@ function MonitorPage() { execs={execs} selectedSession={selectedSession} onSessionSelect={setSelectedSession} + isHydrating={isHydrating} />