Compare commits

...
Author SHA1 Message Date
Luciano Castillo c84182b2e4 feat(ActionGraph): implement async layout calculation for large graphs
- Enhanced the ActionGraph component to support asynchronous layout calculations for large graphs, improving UI responsiveness during layout updates.
- Introduced a loading overlay to indicate layout processing and initial hydration states, providing better user feedback.
- Added a new `isHydrating` prop to manage loading states effectively when fetching graph data.
2026-01-30 16:40:41 -05:00
Luciano Castillo 80ddf4d821 style(ActionGraph): enhance follow mode button appearance
- Updated the styling of the follow mode button in the ActionGraphInner component to include a backdrop blur effect, improving visual feedback for users when the mode is active.
2026-01-30 11:53:56 -05:00
Luciano Castillo 5af254087c feat(graph-layout): implement caching for spawn Y positions to prevent jitter
- Introduced a caching mechanism for spawn Y positions of child sessions to maintain stability during parent action accumulation.
- Updated the layoutGraph function to utilize cached values, reducing recalculation and improving layout consistency.
- Added cleanup logic to remove entries from the cache for sessions that no longer exist, ensuring efficient memory usage.
2026-01-30 11:46:42 -05:00
Luciano Castillo f9dbd9b0bf feat(monitor): implement follow mode in ActionGraph for new nodes
- Added a follow mode feature that automatically pans the viewport to new nodes in the ActionGraph.
- Introduced a button to toggle follow mode, enhancing user experience by allowing users to track new nodes easily.
- Updated the ActionGraphInner component to manage viewport changes and node tracking effectively.
2026-01-30 10:46:07 -05:00
4 changed files with 166 additions and 67 deletions
+22 -48
View File
@@ -18,7 +18,6 @@
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@xyflow/react": "^12.10.0",
"bufferutil": "*",
"framer-motion": "^12.29.0",
"lucide-react": "^0.468.0",
"react": "^19.2.0",
@@ -73,6 +72,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -2281,6 +2281,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -2315,6 +2316,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.157.13.tgz",
"integrity": "sha512-rLr6swdDhLjadAHbukg/XYurNi4q8AmmjP99QiWUnhRPKfrfmQ/O1UCvaowQ9GcaiyOG2COiJ9OlDAQU3k9MFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.154.14",
"@tanstack/react-store": "^0.8.0",
@@ -2461,6 +2463,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.157.13.tgz",
"integrity": "sha512-dl1avpRH2Pi1tYhxDzGEqI6DU9VQUf5zBeW1JJFs3LfJuVkGd2lG93dSUzH9seJNp77hm8JWhoX+k4QYMEB6KA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.154.14",
"@tanstack/store": "^0.8.0",
@@ -2771,6 +2774,7 @@
"https://trpc.io/sponsor"
],
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": ">=5.7.2"
}
@@ -2943,6 +2947,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3171,6 +3176,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3185,20 +3191,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -3403,6 +3395,7 @@
"integrity": "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"srvx": ">=0.7.1"
},
@@ -3444,7 +3437,8 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3-color": {
"version": "3.1.0",
@@ -3503,6 +3497,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3557,6 +3552,7 @@
"integrity": "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@electric-sql/pglite": "*",
"@libsql/client": "*",
@@ -5252,36 +5248,6 @@
}
}
},
"node_modules/nitro/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nitro/node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"dev": true,
"license": "BlueOak-1.0.0",
"optional": true,
"peer": true,
"engines": {
"node": "20 || >=22"
}
},
"node_modules/nitro/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -5289,7 +5255,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
@@ -5445,7 +5410,8 @@
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-2.0.0-alpha.3.tgz",
"integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/ohash": {
"version": "2.0.11",
@@ -5680,6 +5646,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5689,6 +5656,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5817,6 +5785,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -5888,6 +5857,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -6065,6 +6035,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6154,6 +6125,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6380,6 +6352,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6490,6 +6463,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+113 -17
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import {
ReactFlow,
Background,
@@ -6,6 +6,8 @@ import {
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
useOnViewportChange,
type Node,
type Edge,
type NodeTypes,
@@ -13,7 +15,7 @@ import {
MarkerType,
ReactFlowProvider,
} from '@xyflow/react'
import { LayoutGrid, ArrowRightLeft, ArrowUpDown } 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'
@@ -33,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 */
@@ -80,6 +83,7 @@ function ActionGraphInner({
execs,
selectedSession,
onSessionSelect,
isHydrating = false,
}: ActionGraphProps) {
// Crab AI state
const crabRef = useRef<CrabAI>({
@@ -100,6 +104,26 @@ function ActionGraphInner({
// Layout direction: LR = horizontal (sessions spawn right), TB = vertical (sessions stack down)
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR')
// Follow mode: auto-pan to new nodes
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()
// Detect manual panning and auto-disable follow mode
useOnViewportChange({
onEnd: useCallback(() => {
if (followMode && !isAnimatingRef.current) {
setFollowMode(false)
}
}, [followMode]),
})
// Filter actions for selected session, or show all if none selected
const visibleActions = useMemo(() => {
if (!selectedSession) return actions.slice(-50)
@@ -340,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
@@ -457,6 +510,9 @@ function ActionGraphInner({
const prevPositions = nodePositionsRef.current
const crab = crabRef.current
// Track the latest new node for follow mode
let latestNewNode: { x: number; y: number } | null = null
// Check for new nodes
for (const node of layoutedNodes) {
if (!node.id.includes('crab')) {
@@ -470,7 +526,7 @@ function ActionGraphInner({
crab.target = { ...nodeCenter, nodeId: node.id }
crab.state = 'chasing'
if (timeoutRef.current) clearTimeout(timeoutRef.current)
break
latestNewNode = nodeCenter
}
// Existing node moved - if we were tracking it or idle, chase it
@@ -489,8 +545,17 @@ function ActionGraphInner({
}
}
// Follow mode: pan to the latest new node
if (followMode && latestNewNode) {
isAnimatingRef.current = true
setCenter(latestNewNode.x, latestNewNode.y, { zoom: 0.85, duration: 500 })
setTimeout(() => {
isAnimatingRef.current = false
}, 550)
}
prevNodeIdsRef.current = currentIds
}, [layoutedNodes])
}, [layoutedNodes, followMode, setCenter])
// Main animation loop - step-based crab movement at 10fps timing
useEffect(() => {
@@ -754,6 +819,17 @@ function ActionGraphInner({
className="bg-shell-900! border-shell-700! shadow-lg! [&>button]:bg-shell-800! [&>button]:border-shell-700! [&>button]:text-gray-300! [&>button:hover]:bg-shell-700! [&>button>svg]:fill-gray-300!"
/>
<div className="absolute top-2 right-2 z-10 flex gap-1.5">
<button
onClick={() => setFollowMode((prev) => !prev)}
title={followMode ? 'Following new nodes (click to disable)' : 'Follow new nodes'}
className={`p-1.5 rounded border shadow-lg cursor-pointer transition-colors ${
followMode
? 'bg-neon-cyan/20 border-neon-cyan text-neon-cyan backdrop-blur-lg'
: 'bg-shell-800 border-shell-700 text-gray-300 hover:bg-shell-700'
}`}
>
<Crosshair className="w-4 h-4" />
</button>
<button
onClick={() => {
setLayoutDirection((d) => (d === 'LR' ? 'TB' : 'LR'))
@@ -776,6 +852,26 @@ function ActionGraphInner({
<LayoutGrid className="w-4 h-4" />
</button>
</div>
{/* Loading overlay for layout calculation */}
{isPending && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-shell-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-neon-cyan" />
<span className="font-mono text-sm text-gray-400">
Calculating layout for {rawNodes.length.toLocaleString()} nodes...
</span>
</div>
</div>
)}
{/* Loading overlay for initial hydration */}
{isHydrating && layoutedNodes.length === 0 && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-shell-950">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-neon-cyan" />
<span className="font-mono text-sm text-gray-400">Loading graph data...</span>
</div>
</div>
)}
<MiniMap
nodeColor={(node) => {
if (node.type === 'crab') return '#ef4444'
+24 -2
View File
@@ -35,6 +35,10 @@ const ROOT_START_Y = 200 // Vertical offset from crab to first root session
const MIN_SESSION_GAP = 120 // Minimum vertical gap between sessions in same column
const ROOT_HORIZONTAL_GAP = 0 // Gap between root sessions in horizontal mode
// Cache spawn Y positions so they don't change as parent actions accumulate
// Key: session key, Value: calculated spawn Y offset
const spawnYCache = new Map<string, number>()
interface SessionColumn {
sessionKey: string
columnIndex: number
@@ -215,6 +219,7 @@ export function layoutGraph(
// Calculate spawn Y positions for child sessions
// When a session is spawned, find the Y position of the parent at that time
// Cache these values so they don't jitter as parent actions accumulate
for (const session of sessions) {
if (!session.spawnedBy) continue
@@ -222,6 +227,13 @@ export function layoutGraph(
const childCol = sessionColumns.get(session.key)
if (!parentCol || !childCol) continue
// Use cached spawn Y if available (prevents jitter from recalculation)
const cachedSpawnY = spawnYCache.get(session.key)
if (cachedSpawnY !== undefined) {
childCol.spawnY = cachedSpawnY
continue
}
// Find the approximate position in parent where spawn happened
// Use the child's creation time (approximated by first action time or session activity)
const childActions = actionsBySession.get(session.key) ?? []
@@ -239,8 +251,10 @@ export function layoutGraph(
}
}
// Calculate Y based on parent's item count
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
// Calculate Y based on parent's item count and cache it
const calculatedSpawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
spawnYCache.set(session.key, calculatedSpawnY)
childCol.spawnY = calculatedSpawnY
}
// Position all nodes
@@ -362,6 +376,14 @@ export function layoutGraph(
}
}
// Clean up spawn Y cache for sessions that no longer exist
const currentSessionKeys = new Set(sessions.map(s => s.key))
for (const key of spawnYCache.keys()) {
if (!currentSessionKeys.has(key)) {
spawnYCache.delete(key)
}
}
return { nodes: positionedNodes, edges }
}
+7
View File
@@ -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}
/>
</div>
</div>