diff --git a/package-lock.json b/package-lock.json index 220bc2f..dea5b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,7 @@ "@tanstack/react-start": "^1.132.0", "@trpc/client": "^11.0.0", "@trpc/server": "^11.0.0", - "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0", - "dagre": "^0.8.5", "framer-motion": "^12.29.0", "lucide-react": "^0.468.0", "react": "^19.2.0", @@ -70,6 +68,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", @@ -1547,6 +1546,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" }, @@ -1581,6 +1581,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", @@ -1727,6 +1728,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", @@ -2037,6 +2039,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": ">=5.7.2" } @@ -2135,12 +2138,6 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/dagre": { - "version": "0.7.53", - "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", - "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2204,6 +2201,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" } @@ -2432,6 +2430,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2666,7 +2665,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", @@ -2725,6 +2725,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" } @@ -2773,16 +2774,6 @@ "node": ">=12" } }, - "node_modules/dagre": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", - "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", - "license": "MIT", - "dependencies": { - "graphlib": "^2.1.8", - "lodash": "^4.17.15" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3156,15 +3147,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphlib": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", - "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15" - } - }, "node_modules/h3-v2": { "name": "h3", "version": "2.0.1-rc.11", @@ -3707,12 +3689,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4568,6 +4544,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" } @@ -4577,6 +4554,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" }, @@ -4776,6 +4754,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" } @@ -4953,6 +4932,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5042,6 +5022,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" @@ -5258,6 +5239,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", @@ -5368,6 +5350,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 61dc720..e6479a0 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,7 @@ "@tanstack/react-start": "^1.132.0", "@trpc/client": "^11.0.0", "@trpc/server": "^11.0.0", - "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0", - "dagre": "^0.8.5", "framer-motion": "^12.29.0", "lucide-react": "^0.468.0", "react": "^19.2.0", diff --git a/src/components/monitor/ActionGraph.tsx b/src/components/monitor/ActionGraph.tsx index 6cc730b..4f6e58c 100644 --- a/src/components/monitor/ActionGraph.tsx +++ b/src/components/monitor/ActionGraph.tsx @@ -15,18 +15,29 @@ import { import '@xyflow/react/dist/style.css' import { SessionNode } from './SessionNode' import { ActionNode } from './ActionNode' +import { ExecNode } from './ExecNode' import { CrabNode } from './CrabNode' import { ChaserCrabNode, type ChaserCrabState } from './ChaserCrabNode' import { layoutGraph } from '~/lib/graph-layout' -import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot' +import type { + MonitorSession, + MonitorAction, + MonitorExecProcess, +} from '~/integrations/clawdbot' interface ActionGraphProps { sessions: MonitorSession[] actions: MonitorAction[] + execs: MonitorExecProcess[] selectedSession: string | null onSessionSelect: (key: string | null) => void } +/** Cast domain data to ReactFlow's Node data type */ +function nodeData(data: T): Record { + return data as Record +} + const CRAB_NODE_ID = 'crab-origin' const CHASER_CRAB_ID = 'chaser-crab' @@ -46,6 +57,7 @@ const SIDEWAYS_DRIFT = 0.4 // crabs scuttle sideways const nodeTypes: NodeTypes = { session: SessionNode as any, action: ActionNode as any, + exec: ExecNode as any, crab: CrabNode as any, chaserCrab: ChaserCrabNode as any, } @@ -63,6 +75,7 @@ interface CrabAI { function ActionGraphInner({ sessions, actions, + execs, selectedSession, onSessionSelect, }: ActionGraphProps) { @@ -87,11 +100,17 @@ function ActionGraphInner({ return actions.filter((a) => a.sessionKey === selectedSession) }, [actions, selectedSession]) + const visibleExecs = useMemo(() => { + if (!selectedSession) return execs.slice(-50) + return execs.filter((exec) => exec.sessionKey === selectedSession) + }, [execs, selectedSession]) + // Build nodes const rawNodes = useMemo(() => { const nodes: Node[] = [] - const hasActivity = sessions.length > 0 || visibleActions.length > 0 + const hasActivity = + sessions.length > 0 || visibleActions.length > 0 || visibleExecs.length > 0 nodes.push({ id: CRAB_NODE_ID, type: 'crab', @@ -108,7 +127,7 @@ function ActionGraphInner({ id: `session-${session.key}`, type: 'session', position: { x: 0, y: 0 }, - data: session as unknown as Record, + data: nodeData(session), }) } @@ -117,12 +136,21 @@ function ActionGraphInner({ id: `action-${action.id}`, type: 'action', position: { x: 0, y: 0 }, - data: action as unknown as Record, + data: nodeData(action), + }) + } + + for (const exec of visibleExecs) { + nodes.push({ + id: `exec-${exec.id}`, + type: 'exec', + position: { x: 0, y: 0 }, + data: nodeData(exec), }) } return nodes - }, [sessions, visibleActions, selectedSession]) + }, [sessions, visibleActions, visibleExecs, selectedSession]) // Build edges const rawEdges = useMemo(() => { @@ -132,18 +160,23 @@ function ActionGraphInner({ ? sessions.filter((s) => s.key === selectedSession) : sessions - for (const session of visibleSessions) { - edges.push({ - id: `e-crab-${session.key}`, - source: CRAB_NODE_ID, - target: `session-${session.key}`, - markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' }, - style: { stroke: '#ef4444', strokeWidth: 2 }, - }) + // Build a set of visible session keys for parent lookup + const visibleSessionKeys = new Set(visibleSessions.map((s) => s.key)) + + // Edge styles + const spawnEdgeStyle = { + animated: true, + style: { stroke: '#00ffd5', strokeWidth: 2, strokeDasharray: '8 4' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' }, } - const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`)) + const crabEdgeStyle = { + animated: false, + style: { stroke: '#ef4444', strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' }, + } + // Group actions by session for spawn point lookup const sessionActions = new Map() for (const action of visibleActions) { const key = action.sessionKey @@ -152,6 +185,39 @@ function ActionGraphInner({ list.push(action) sessionActions.set(key, list) } + // Sort each session's actions by timestamp + for (const [key, actions] of sessionActions) { + sessionActions.set(key, [...actions].sort((a, b) => a.timestamp - b.timestamp)) + } + + // Connect sessions to their spawn sources + for (const session of visibleSessions) { + const parentSessionKey = session.spawnedBy + + if (parentSessionKey && visibleSessionKeys.has(parentSessionKey)) { + // This session was spawned by another session + // Connect from parent's right handle to child's left handle (horizontal spawn) + edges.push({ + id: `e-spawn-${session.key}`, + source: `session-${parentSessionKey}`, + target: `session-${session.key}`, + sourceHandle: 'spawn-source', + targetHandle: 'spawn-target', + type: 'smoothstep', + ...spawnEdgeStyle, + }) + } else { + // Root session - connect from crab + edges.push({ + id: `e-crab-${session.key}`, + source: CRAB_NODE_ID, + target: `session-${session.key}`, + ...crabEdgeStyle, + }) + } + } + + const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`)) const getEdgeStyle = (action: MonitorAction) => { switch (action.type) { @@ -194,8 +260,9 @@ function ActionGraphInner({ } } + // Connect actions within each session (vertical flow) for (const [sessionKey, actions] of sessionActions) { - const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp) + const sorted = actions // Already sorted above const sessionId = `session-${sessionKey}` for (let i = 0; i < sorted.length; i++) { @@ -203,6 +270,7 @@ function ActionGraphInner({ const edgeStyle = getEdgeStyle(action) if (i === 0) { + // First action connects from session node if (sessionNodeIds.has(sessionId)) { edges.push({ id: `e-session-${action.id}`, @@ -212,6 +280,7 @@ function ActionGraphInner({ }) } } else { + // Subsequent actions connect from previous action const prev = sorted[i - 1]! edges.push({ id: `e-${prev.id}-${action.id}`, @@ -223,8 +292,47 @@ function ActionGraphInner({ } } + const getExecEdgeStyle = (exec: MonitorExecProcess) => { + switch (exec.status) { + case 'running': + return { + animated: true, + style: { stroke: '#00ffd5', strokeDasharray: '4 4' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' }, + } + case 'failed': + return { + animated: false, + style: { stroke: '#ef4444' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' }, + } + case 'completed': + default: + return { + animated: false, + style: { stroke: '#98ffc8' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' }, + } + } + } + + // Connect execs to their session + for (const exec of visibleExecs) { + const key = exec.sessionKey + if (!key) continue + const sessionId = `session-${key}` + if (!sessionNodeIds.has(sessionId)) continue + const edgeStyle = getExecEdgeStyle(exec) + edges.push({ + id: `e-session-exec-${exec.id}`, + source: sessionId, + target: `exec-${exec.id}`, + ...edgeStyle, + }) + } + return edges - }, [sessions, visibleActions, selectedSession]) + }, [sessions, visibleActions, visibleExecs, selectedSession]) // Apply layout const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { @@ -608,6 +716,12 @@ function ActionGraphInner({ if (node.type === 'crab') return '#ef4444' if (node.type === 'chaserCrab') return '#ef4444' if (node.type === 'session') return '#98ffc8' + if (node.type === 'exec') { + const status = (node.data as unknown as MonitorExecProcess).status + if (status === 'running') return '#00ffd5' + if (status === 'failed') return '#ef4444' + return '#98ffc8' + } return '#52526e' }} maskColor="rgba(10, 10, 15, 0.8)" diff --git a/src/components/monitor/ExecNode.tsx b/src/components/monitor/ExecNode.tsx new file mode 100644 index 0000000..4bc344e --- /dev/null +++ b/src/components/monitor/ExecNode.tsx @@ -0,0 +1,250 @@ +import { memo, useCallback, useMemo, useState } from 'react' +import { Handle, Position } from '@xyflow/react' +import { motion, AnimatePresence } from 'framer-motion' +import { CheckCircle, Copy, Check, Loader2, Terminal, XCircle } from 'lucide-react' +import type { MonitorExecProcess, MonitorExecOutputChunk } from '~/integrations/clawdbot' + +interface ExecNodeProps { + data: MonitorExecProcess + selected?: boolean +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + const secs = ms / 1000 + if (secs < 60) return `${secs.toFixed(1)}s` + const mins = Math.floor(secs / 60) + const remainSecs = Math.floor(secs % 60) + return `${mins}m ${remainSecs}s` +} + +function tailLinesFromChunks(chunks: MonitorExecOutputChunk[], maxLines: number): string { + if (chunks.length === 0) return '' + const merged = chunks.map((c) => c.text).join('') + const lines = merged.split(/\r?\n/) + return lines.slice(-maxLines).join('\n').trim() +} + +const statusConfig: Record< + MonitorExecProcess['status'], + { + icon: typeof Loader2 + borderColor: string + badgeColor: string + iconColor: string + animate: boolean + label: string + } +> = { + running: { + icon: Loader2, + borderColor: 'border-neon-cyan', + badgeColor: 'bg-neon-cyan/15 text-neon-cyan', + iconColor: 'text-neon-cyan', + animate: true, + label: 'Running', + }, + completed: { + icon: CheckCircle, + borderColor: 'border-neon-mint', + badgeColor: 'bg-neon-mint/15 text-neon-mint', + iconColor: 'text-neon-mint', + animate: false, + label: 'Completed', + }, + failed: { + icon: XCircle, + borderColor: 'border-crab-500', + badgeColor: 'bg-crab-500/15 text-crab-300', + iconColor: 'text-crab-400', + animate: false, + label: 'Failed', + }, +} + +function streamStyle(stream: MonitorExecOutputChunk['stream']): string { + if (stream === 'stderr') { + return 'text-crab-200 bg-crab-950/30 border-crab-900/60' + } + return 'text-neon-cyan/90 bg-shell-950 border-shell-800' +} + +export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps) { + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + const status = statusConfig[data.status] + const StatusIcon = status.icon + + const preview = useMemo(() => tailLinesFromChunks(data.outputs, 3), [data.outputs]) + const hasOutput = data.outputs.length > 0 + + const handleCopyPid = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + navigator.clipboard.writeText(String(data.pid)).catch(() => {}) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }, [data.pid]) + + const displayDuration = + data.durationMs != null + ? formatDuration(data.durationMs) + : data.completedAt != null + ? formatDuration(Math.max(0, data.completedAt - data.startedAt)) + : null + + return ( + setExpanded((prev) => !prev)} + className={` + px-3 py-2.5 rounded-lg border-2 min-w-[220px] cursor-pointer + bg-shell-900 ${status.borderColor} + ${selected ? 'ring-2 ring-white/30' : ''} + ${expanded ? 'max-w-[680px]' : 'max-w-[360px]'} + transition-all duration-150 hover:bg-shell-800 + `} + style={{ + boxShadow: selected + ? '0 0 15px rgba(239, 68, 68, 0.3)' + : '0 4px 12px rgba(0, 0, 0, 0.35)', + }} + > + + +
+ + + Exec + + + {data.command} + +
+ + +
+
+ +
+ > {formatTime(data.lastActivityAt)} +
+ +
+ + pid: {data.pid} + + {data.exitCode != null && ( + + exit:{' '} + + {data.exitCode} + + + )} + {displayDuration && ( + {displayDuration} + )} + {data.status === 'running' && ( + live + )} +
+ + {data.outputTruncated && ( +
+ output truncated +
+ )} + + {hasOutput && !expanded && ( +
+          {preview || '(no output)'}
+        
+ )} + + {hasOutput && expanded && ( +
+
+ {status.label} + {data.outputs.length} chunks +
+
+ {data.outputs.map((chunk) => ( +
+
+ + {chunk.stream} + + {formatTime(chunk.timestamp)} +
+
+                  {chunk.text}
+                
+
+ ))} +
+
+ )} + + +
+ ) +}) + diff --git a/src/components/monitor/SessionNode.tsx b/src/components/monitor/SessionNode.tsx index 5737c0b..88c3c52 100644 --- a/src/components/monitor/SessionNode.tsx +++ b/src/components/monitor/SessionNode.tsx @@ -1,7 +1,7 @@ -import { memo } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { Handle, Position } from '@xyflow/react' -import { motion } from 'framer-motion' -import { Users, User } from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' +import { Users, User, Clock, Copy, Check } from 'lucide-react' import { StatusIndicator } from './StatusIndicator' import type { MonitorSession } from '~/integrations/clawdbot' @@ -15,13 +15,50 @@ const platformIcons: Record = { telegram: '✈️', discord: '🎮', slack: '💼', + subagent: '🤖', +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now() + const diff = now - timestamp + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (seconds < 60) return 'just now' + if (minutes < 60) return `${minutes}m ago` + if (hours < 24) return `${hours}h ago` + return `${days}d ago` } export const SessionNode = memo(function SessionNode({ data, selected, }: SessionNodeProps) { - const platformIcon = platformIcons[data.platform] ?? '📱' + const [copied, setCopied] = useState(false) + const [, setTick] = useState(0) + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 30_000) + return () => clearInterval(id) + }, []) + + // Detect if this is a subagent session by checking if platform is "subagent" or if key contains "subagent" + const isSubagent = data.platform === 'subagent' || data.key.includes('subagent') || Boolean(data.spawnedBy) + const platformIcon = isSubagent ? platformIcons.subagent : (platformIcons[data.platform] ?? '📱') + const displayPlatform = isSubagent ? 'subagent' : data.platform + + const relativeTime = (!data.lastActivityAt || data.lastActivityAt <= 0) + ? null + : formatRelativeTime(data.lastActivityAt) + + const handleCopyKey = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + navigator.clipboard.writeText(data.key).catch(() => {}) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }, [data.key]) return ( +
{platformIcon} - {data.platform} + {displayPlatform} - +
+ + +
@@ -65,7 +137,15 @@ export const SessionNode = memo(function SessionNode({ > {data.agentId}
+ {relativeTime && ( +
+ + {relativeTime} +
+ )} + +
) }) diff --git a/src/components/monitor/index.ts b/src/components/monitor/index.ts index 3341223..1b4d743 100644 --- a/src/components/monitor/index.ts +++ b/src/components/monitor/index.ts @@ -2,6 +2,7 @@ export { ActionGraph } from './ActionGraph' export { SessionList } from './SessionList' export { SessionNode } from './SessionNode' export { ActionNode } from './ActionNode' +export { ExecNode } from './ExecNode' export { CrabNode } from './CrabNode' export { StatusIndicator } from './StatusIndicator' export { SettingsPanel } from './SettingsPanel' diff --git a/src/integrations/clawdbot/collections.ts b/src/integrations/clawdbot/collections.ts index 4d20e1d..753e117 100644 --- a/src/integrations/clawdbot/collections.ts +++ b/src/integrations/clawdbot/collections.ts @@ -1,5 +1,13 @@ import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' -import { parseSessionKey, type MonitorSession, type MonitorAction } from './protocol' +import { + parseSessionKey, + type MonitorSession, + type MonitorAction, + type MonitorExecEvent, + type MonitorExecProcess, + type MonitorExecOutputChunk, + type MonitorExecProcessStatus, +} from './protocol' // Track runId → sessionKey mapping (learned from chat events) const runSessionMap = new Map() @@ -60,6 +68,100 @@ export const actionsCollection = createCollection( }) ) +export const execsCollection = createCollection( + localOnlyCollectionOptions({ + id: 'clawdbot-execs', + getKey: (item) => item.id, + }) +) + +const EXEC_PLACEHOLDER_COMMAND = 'Exec' +const MAX_EXEC_OUTPUT_CHUNKS = 200 +const MAX_EXEC_OUTPUT_CHARS = 50000 +const MAX_EXEC_CHUNK_CHARS = 4000 + +function resolveSessionKey(event: MonitorExecEvent): string | undefined { + return event.sessionKey || runSessionMap.get(event.runId) || event.sessionId +} + +function backfillExecSessionKey(runId: string, sessionKey: string) { + for (const exec of execsCollection.state.values()) { + if (exec.runId !== runId) continue + if (exec.sessionKey && exec.sessionKey !== exec.sessionId) continue + execsCollection.update(exec.id, (draft) => { + draft.sessionKey = sessionKey + }) + } +} + +function mapExecStatus(exitCode?: number, status?: string): MonitorExecProcessStatus { + if (typeof exitCode === 'number' && exitCode !== 0) return 'failed' + if (typeof status === 'string') { + const normalized = status.toLowerCase() + if (normalized.includes('fail') || normalized.includes('error')) { + return 'failed' + } + } + return 'completed' +} + +function capExecOutputs(outputs: MonitorExecOutputChunk[]): { + outputs: MonitorExecOutputChunk[] + truncated: boolean +} { + let truncated = false + const normalized: MonitorExecOutputChunk[] = outputs.map((chunk) => { + if (chunk.text.length <= MAX_EXEC_CHUNK_CHARS) { + return chunk + } + truncated = true + return { + ...chunk, + text: chunk.text.slice(0, MAX_EXEC_CHUNK_CHARS) + '\n...[truncated]', + } + }) + + let capped = normalized + if (capped.length > MAX_EXEC_OUTPUT_CHUNKS) { + truncated = true + capped = capped.slice(-MAX_EXEC_OUTPUT_CHUNKS) + } + + const totalChars = capped.reduce((sum, chunk) => sum + chunk.text.length, 0) + if (totalChars > MAX_EXEC_OUTPUT_CHARS) { + truncated = true + let dropped = 0 + let startIdx = 0 + for (let i = 0; i < capped.length; i++) { + if (totalChars - dropped <= MAX_EXEC_OUTPUT_CHARS) break + dropped += capped[i]!.text.length + startIdx = i + 1 + } + capped = capped.slice(startIdx) + } + + return { outputs: capped, truncated } +} + +function createPlaceholderExec(event: MonitorExecEvent, sessionKey?: string): MonitorExecProcess { + const startedAt = event.startedAt ?? event.timestamp + return { + id: event.execId, + runId: event.runId, + pid: event.pid, + command: event.command || EXEC_PLACEHOLDER_COMMAND, + sessionId: event.sessionId, + sessionKey, + status: event.eventType === 'completed' + ? mapExecStatus(event.exitCode, event.status) + : 'running', + startedAt, + timestamp: startedAt, + outputs: [], + lastActivityAt: event.timestamp, + } +} + // Helper to update or insert session export function upsertSession(session: MonitorSession) { // Track activity on parent sessions @@ -100,7 +202,11 @@ export function upsertSession(session: MonitorSession) { export function addAction(action: MonitorAction) { // Learn runId → sessionKey mapping from actions with real session keys if (action.sessionKey && !action.sessionKey.includes('lifecycle')) { + const previous = runSessionMap.get(action.runId) runSessionMap.set(action.runId, action.sessionKey) + if (previous !== action.sessionKey) { + backfillExecSessionKey(action.runId, action.sessionKey) + } // Track activity on parent sessions for spawn inference if (isParentSession(action.sessionKey)) { @@ -191,6 +297,98 @@ export function addAction(action: MonitorAction) { } } +export function addExecEvent(event: MonitorExecEvent) { + const sessionKey = resolveSessionKey(event) + const existing = execsCollection.state.get(event.execId) + + if (event.eventType === 'started') { + if (existing) { + execsCollection.update(event.execId, (draft) => { + draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND + draft.sessionId = event.sessionId || draft.sessionId + draft.sessionKey = sessionKey || draft.sessionKey + draft.status = 'running' + draft.startedAt = event.startedAt ?? draft.startedAt ?? event.timestamp + draft.timestamp = draft.startedAt + draft.lastActivityAt = event.timestamp + }) + return + } + + execsCollection.insert({ + ...createPlaceholderExec(event, sessionKey), + command: event.command || EXEC_PLACEHOLDER_COMMAND, + startedAt: event.startedAt ?? event.timestamp, + timestamp: event.startedAt ?? event.timestamp, + }) + return + } + + if (event.eventType === 'output') { + const stream = event.stream || 'stdout' + const text = event.output ?? '' + const chunk: MonitorExecOutputChunk = { + id: event.id, + stream, + text, + timestamp: event.timestamp, + } + + if (existing) { + execsCollection.update(event.execId, (draft) => { + draft.sessionId = event.sessionId || draft.sessionId + draft.sessionKey = sessionKey || draft.sessionKey + draft.lastActivityAt = event.timestamp + if (text) { + const capped = capExecOutputs([...draft.outputs, chunk]) + draft.outputs = capped.outputs + draft.outputTruncated = draft.outputTruncated || capped.truncated + } + }) + return + } + + const placeholder = createPlaceholderExec(event, sessionKey) + if (text) { + const capped = capExecOutputs([chunk]) + placeholder.outputs = capped.outputs + placeholder.outputTruncated = capped.truncated + } + execsCollection.insert(placeholder) + return + } + + if (event.eventType === 'completed') { + const completedStatus = mapExecStatus(event.exitCode, event.status) + if (existing) { + execsCollection.update(event.execId, (draft) => { + draft.sessionId = event.sessionId || draft.sessionId + draft.sessionKey = sessionKey || draft.sessionKey + draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND + draft.exitCode = event.exitCode ?? draft.exitCode + draft.durationMs = event.durationMs ?? draft.durationMs + const completedAt = draft.durationMs != null + ? draft.startedAt + draft.durationMs + : event.timestamp + draft.completedAt = completedAt + draft.status = completedStatus + draft.lastActivityAt = event.timestamp + }) + return + } + + const placeholder = createPlaceholderExec(event, sessionKey) + placeholder.command = event.command || placeholder.command + placeholder.exitCode = event.exitCode + placeholder.durationMs = event.durationMs + placeholder.completedAt = placeholder.durationMs != null + ? placeholder.startedAt + placeholder.durationMs + : event.timestamp + placeholder.status = completedStatus + execsCollection.insert(placeholder) + } +} + // Helper to update session status export function updateSessionStatus( key: string, @@ -246,12 +444,61 @@ export function clearCollections() { for (const action of actionsCollection.state.values()) { actionsCollection.delete(action.id) } + for (const exec of execsCollection.state.values()) { + execsCollection.delete(exec.id) + } +} + +// Get count of completed/failed execs (for UI badge) +export function getCompletedExecCount(): number { + let count = 0 + for (const exec of execsCollection.state.values()) { + if (exec.status === 'completed' || exec.status === 'failed') { + count++ + } + } + return count +} + +// Clear completed and failed execs from state +// Returns number of items cleared +export function clearCompletedExecs(): number { + const toDelete: string[] = [] + for (const exec of execsCollection.state.values()) { + if (exec.status === 'completed' || exec.status === 'failed') { + toDelete.push(exec.id) + } + } + for (const id of toDelete) { + execsCollection.delete(id) + } + return toDelete.length +} + +// Clear inactive sessions (idle sessions with no activity for thresholdMs) +// Returns number of sessions cleared +export function clearInactiveSessions(thresholdMs: number): number { + const now = Date.now() + const toDelete: string[] = [] + for (const session of sessionsCollection.state.values()) { + // Only clear idle sessions - preserve thinking/active ones + if (session.status !== 'idle') continue + const inactiveTime = now - session.lastActivityAt + if (inactiveTime >= thresholdMs) { + toDelete.push(session.key) + } + } + for (const key of toDelete) { + sessionsCollection.delete(key) + } + return toDelete.length } // Hydrate collections from server persistence export function hydrateFromServer( sessions: MonitorSession[], - actions: MonitorAction[] + actions: MonitorAction[], + execEvents: MonitorExecEvent[] = [] ) { // First clear existing data clearCollections() @@ -286,4 +533,10 @@ export function hydrateFromServer( for (const action of sortedActions) { addAction(action) } + + // Replay exec events after actions to maximize sessionKey resolution + const sortedExecEvents = [...execEvents].sort((a, b) => a.timestamp - b.timestamp) + for (const event of sortedExecEvents) { + addExecEvent(event) + } } diff --git a/src/integrations/clawdbot/parser.ts b/src/integrations/clawdbot/parser.ts index 850c980..11bca84 100644 --- a/src/integrations/clawdbot/parser.ts +++ b/src/integrations/clawdbot/parser.ts @@ -2,8 +2,12 @@ import type { EventFrame, ChatEvent, AgentEvent, + ExecStartedEvent, + ExecOutputEvent, + ExecCompletedEvent, MonitorSession, MonitorAction, + MonitorExecEvent, SessionInfo, } from './protocol' import { parseSessionKey } from './protocol' @@ -148,7 +152,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction { export function parseEventFrame( frame: EventFrame -): { session?: Partial; action?: MonitorAction } | null { +): { + session?: Partial + action?: MonitorAction + execEvent?: MonitorExecEvent +} | null { // Skip system events if (frame.event === 'health' || frame.event === 'tick') { return null @@ -189,5 +197,95 @@ export function parseEventFrame( return null } + if (frame.event === 'exec.started' && frame.payload) { + const exec = frame.payload as ExecStartedEvent + const execId = `exec-${exec.runId}-${exec.pid}` + const timestamp = Date.now() + const id = frame.seq != null + ? `${execId}-started-${frame.seq}` + : `${execId}-started-${timestamp}` + + return { + execEvent: { + id, + execId, + runId: exec.runId, + pid: exec.pid, + sessionId: exec.sessionId, + eventType: 'started', + command: exec.command, + startedAt: exec.startedAt, + timestamp, + }, + session: exec.sessionId + ? { + key: exec.sessionId, + status: 'thinking', + lastActivityAt: timestamp, + } + : undefined, + } + } + + if (frame.event === 'exec.output' && frame.payload) { + const exec = frame.payload as ExecOutputEvent + const execId = `exec-${exec.runId}-${exec.pid}` + const timestamp = Date.now() + const id = frame.seq != null + ? `${execId}-output-${frame.seq}` + : `${execId}-output-${timestamp}` + + return { + execEvent: { + id, + execId, + runId: exec.runId, + pid: exec.pid, + sessionId: exec.sessionId, + eventType: 'output', + stream: exec.stream, + output: exec.output, + timestamp, + }, + session: exec.sessionId + ? { + key: exec.sessionId, + lastActivityAt: timestamp, + } + : undefined, + } + } + + if (frame.event === 'exec.completed' && frame.payload) { + const exec = frame.payload as ExecCompletedEvent + const execId = `exec-${exec.runId}-${exec.pid}` + const timestamp = Date.now() + const id = frame.seq != null + ? `${execId}-completed-${frame.seq}` + : `${execId}-completed-${timestamp}` + + return { + execEvent: { + id, + execId, + runId: exec.runId, + pid: exec.pid, + sessionId: exec.sessionId, + eventType: 'completed', + durationMs: exec.durationMs, + exitCode: exec.exitCode, + status: exec.status, + timestamp, + }, + session: exec.sessionId + ? { + key: exec.sessionId, + status: 'active', + lastActivityAt: timestamp, + } + : undefined, + } + } + return null } diff --git a/src/integrations/clawdbot/persistence.ts b/src/integrations/clawdbot/persistence.ts index eea376b..d16470a 100644 --- a/src/integrations/clawdbot/persistence.ts +++ b/src/integrations/clawdbot/persistence.ts @@ -1,12 +1,14 @@ import fs from 'fs' import path from 'path' -import type { MonitorSession, MonitorAction } from './protocol' +import type { MonitorSession, MonitorAction, MonitorExecEvent } from './protocol' const DATA_DIR = path.join(process.cwd(), 'data') const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json') const ACTIONS_FILE = path.join(DATA_DIR, 'actions.jsonl') +const EXEC_EVENTS_FILE = path.join(DATA_DIR, 'exec-events.jsonl') const STATE_FILE = path.join(DATA_DIR, 'state.json') const MAX_ACTIONS = 10000 +const MAX_EXEC_EVENTS = 20000 interface PersistenceState { enabled: boolean @@ -16,6 +18,7 @@ interface PersistenceState { class PersistenceService { private sessions: Map = new Map() private actions: MonitorAction[] = [] + private execEvents: MonitorExecEvent[] = [] private enabled = false private startedAt: number | null = null @@ -90,6 +93,28 @@ class PersistenceService { } catch { // ignore } + + // Load exec events (JSONL) + try { + if (fs.existsSync(EXEC_EVENTS_FILE)) { + const content = fs.readFileSync(EXEC_EVENTS_FILE, 'utf-8') + const lines = content.trim().split('\n').filter(Boolean) + for (const line of lines) { + try { + const event = JSON.parse(line) as MonitorExecEvent + this.execEvents.push(event) + } catch { + // skip bad lines + } + } + if (this.execEvents.length > MAX_EXEC_EVENTS) { + this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS) + this.saveExecEvents() + } + } + } catch { + // ignore + } } private saveSessions() { @@ -102,10 +127,19 @@ class PersistenceService { fs.writeFileSync(ACTIONS_FILE, content) } + private saveExecEvents() { + const content = this.execEvents.map((e) => JSON.stringify(e)).join('\n') + fs.writeFileSync(EXEC_EVENTS_FILE, content) + } + private appendAction(action: MonitorAction) { fs.appendFileSync(ACTIONS_FILE, JSON.stringify(action) + '\n') } + private appendExecEvent(event: MonitorExecEvent) { + fs.appendFileSync(EXEC_EVENTS_FILE, JSON.stringify(event) + '\n') + } + get isEnabled() { return this.enabled } @@ -131,12 +165,14 @@ class PersistenceService { startedAt: number | null sessionCount: number actionCount: number + execEventCount: number } { return { enabled: this.enabled, startedAt: this.startedAt, sessionCount: this.sessions.size, actionCount: this.actions.length, + execEventCount: this.execEvents.length, } } @@ -168,19 +204,44 @@ class PersistenceService { } } - hydrate(): { sessions: MonitorSession[]; actions: MonitorAction[] } { + addExecEvent(event: MonitorExecEvent) { + if (!this.enabled) return + + const existingIdx = this.execEvents.findIndex((e) => e.id === event.id) + if (existingIdx >= 0) { + this.execEvents[existingIdx] = event + this.saveExecEvents() + } else { + this.execEvents.push(event) + this.appendExecEvent(event) + + if (this.execEvents.length > MAX_EXEC_EVENTS) { + this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS) + this.saveExecEvents() + } + } + } + + hydrate(): { + sessions: MonitorSession[] + actions: MonitorAction[] + execEvents: MonitorExecEvent[] + } { return { sessions: Array.from(this.sessions.values()), actions: [...this.actions], + execEvents: [...this.execEvents], } } clear(): { cleared: boolean } { this.sessions.clear() this.actions = [] + this.execEvents = [] try { if (fs.existsSync(SESSIONS_FILE)) fs.unlinkSync(SESSIONS_FILE) if (fs.existsSync(ACTIONS_FILE)) fs.unlinkSync(ACTIONS_FILE) + if (fs.existsSync(EXEC_EVENTS_FILE)) fs.unlinkSync(EXEC_EVENTS_FILE) } catch { // ignore } diff --git a/src/integrations/clawdbot/protocol.ts b/src/integrations/clawdbot/protocol.ts index 2239847..7fda5e1 100644 --- a/src/integrations/clawdbot/protocol.ts +++ b/src/integrations/clawdbot/protocol.ts @@ -85,6 +85,32 @@ export interface AgentEvent { sessionKey?: string } +// Exec events +export interface ExecStartedEvent { + pid: number + command: string + sessionId: string + runId: string + startedAt: number +} + +export interface ExecOutputEvent { + pid: number + runId: string + sessionId?: string + stream: 'stdout' | 'stderr' | string + output: string +} + +export interface ExecCompletedEvent { + pid: number + runId: string + sessionId?: string + exitCode: number + durationMs: number + status: string +} + // Sessions export interface SessionsListParams { limit?: number @@ -137,6 +163,53 @@ export interface MonitorAction { stopReason?: string } +export type MonitorExecEventType = 'started' | 'output' | 'completed' + +export interface MonitorExecEvent { + id: string + execId: string + runId: string + pid: number + sessionId?: string + sessionKey?: string + eventType: MonitorExecEventType + command?: string + stream?: 'stdout' | 'stderr' | string + output?: string + startedAt?: number + durationMs?: number + exitCode?: number + status?: string + timestamp: number +} + +export type MonitorExecProcessStatus = 'running' | 'completed' | 'failed' + +export interface MonitorExecOutputChunk { + id: string + stream: 'stdout' | 'stderr' | string + text: string + timestamp: number +} + +export interface MonitorExecProcess { + id: string + runId: string + pid: number + command: string + sessionId?: string + sessionKey?: string + status: MonitorExecProcessStatus + startedAt: number + completedAt?: number + durationMs?: number + exitCode?: number + outputs: MonitorExecOutputChunk[] + outputTruncated?: boolean + timestamp: number + lastActivityAt: number +} + // Utility functions export function parseSessionKey(key: string): { agentId: string diff --git a/src/integrations/trpc/router.ts b/src/integrations/trpc/router.ts index 2c9e6df..9accd83 100644 --- a/src/integrations/trpc/router.ts +++ b/src/integrations/trpc/router.ts @@ -9,6 +9,7 @@ import { sessionInfoToMonitor, type MonitorSession, type MonitorAction, + type MonitorExecEvent, } from '~/integrations/clawdbot' // Server-side debug mode state @@ -137,9 +138,10 @@ const clawdbotRouter = router({ events: publicProcedure.subscription(() => { return observable<{ - type: 'session' | 'action' + type: 'session' | 'action' | 'exec' session?: Partial action?: MonitorAction + execEvent?: MonitorExecEvent }>((emit) => { const client = getClawdbotClient() const persistence = getPersistenceService() @@ -163,6 +165,9 @@ const clawdbotRouter = router({ if (debugMode && parsed.action) { console.log('[DEBUG] Parsed action:', parsed.action.type, parsed.action.eventType, 'sessionKey:', parsed.action.sessionKey) } + if (debugMode && parsed.execEvent) { + console.log('[DEBUG] Parsed exec:', parsed.execEvent.eventType, 'runId:', parsed.execEvent.runId, 'pid:', parsed.execEvent.pid) + } if (parsed.session) { emit.next({ type: 'session', session: parsed.session }) } @@ -171,6 +176,10 @@ const clawdbotRouter = router({ persistence.addAction(parsed.action) emit.next({ type: 'action', action: parsed.action }) } + if (parsed.execEvent) { + persistence.addExecEvent(parsed.execEvent) + emit.next({ type: 'exec', execEvent: parsed.execEvent }) + } } }) diff --git a/src/lib/graph-layout.ts b/src/lib/graph-layout.ts index 09eba37..51e2893 100644 --- a/src/lib/graph-layout.ts +++ b/src/lib/graph-layout.ts @@ -1,5 +1,14 @@ -import dagre from 'dagre' import type { Node, Edge } from '@xyflow/react' +import type { + MonitorSession, + MonitorAction, + MonitorExecProcess, +} from '~/integrations/clawdbot' + +/** Cast domain data to ReactFlow's Node data type */ +function nodeData(data: T): Record { + return data as Record +} export interface LayoutOptions { direction?: 'TB' | 'LR' | 'BT' | 'RL' @@ -9,60 +18,291 @@ export interface LayoutOptions { nodeSep?: number } +// Node sizing configuration - sized generously for layout calculations +const NODE_DIMENSIONS = { + session: { width: 280, height: 140 }, // Wider for session cards + exec: { width: 300, height: 120 }, // Exec processes need room + action: { width: 220, height: 100 }, // Chat events with padding + crab: { width: 64, height: 64 }, +} + +// Layout constants - generous spacing for clarity +const COLUMN_GAP = 400 // Horizontal gap between session columns +const ROW_GAP = 80 // Vertical gap between items in a column +const SPAWN_OFFSET = 60 // Extra Y offset when spawning to right +const CRAB_OFFSET = { x: -120, y: -100 } +const MIN_SESSION_GAP = 120 // Minimum vertical gap between sessions in same column + +interface SessionColumn { + sessionKey: string + columnIndex: number + spawnY: number // Y position where this session was spawned from parent + items: Array<{ + nodeId: string + type: 'session' | 'action' | 'exec' + timestamp: number + data: unknown + }> +} + +/** + * Horizontal spawn layout algorithm: + * - Sessions arranged in columns (X = spawn depth) + * - Events within a session flow DOWN (Y = time progression) + * - Child sessions appear to the RIGHT at the Y-level where they were spawned + */ export function layoutGraph( nodes: Node[], edges: Edge[], - options: LayoutOptions = {} + _options: LayoutOptions = {} ): { nodes: Node[]; edges: Edge[] } { - const { - direction = 'TB', - nodeWidth = 200, - nodeHeight = 80, - rankSep = 80, - nodeSep = 40, - } = options + // Build session hierarchy and columns + const sessions = nodes + .filter((n) => n.type === 'session') + .map((n) => n.data as unknown as MonitorSession) - const g = new dagre.graphlib.Graph() - g.setDefaultEdgeLabel(() => ({})) - g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep }) + const actions = nodes + .filter((n) => n.type === 'action') + .map((n) => ({ id: n.id.replace('action-', ''), data: n.data as unknown as MonitorAction })) - // Add nodes - for (const node of nodes) { - const width = node.measured?.width ?? nodeWidth - const height = node.measured?.height ?? nodeHeight - g.setNode(node.id, { width, height }) - } + const execs = nodes + .filter((n) => n.type === 'exec') + .map((n) => ({ id: n.id.replace('exec-', ''), data: n.data as unknown as MonitorExecProcess })) - // Add edges - for (const edge of edges) { - g.setEdge(edge.source, edge.target) - } + const crabNode = nodes.find((n) => n.type === 'crab') - // Run layout - dagre.layout(g) + // Build session column map - which column is each session in? + const sessionColumns = new Map() + const columnOccupancy = new Map() // columnIndex -> maxY used - // Apply positions - const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = g.node(node.id) - const width = node.measured?.width ?? nodeWidth - const height = node.measured?.height ?? nodeHeight + // First pass: determine column for each session based on spawn hierarchy + const getSessionColumn = (sessionKey: string, visited = new Set()): number => { + if (visited.has(sessionKey)) return 0 + visited.add(sessionKey) - return { - ...node, - position: { - x: nodeWithPosition.x - width / 2, - y: nodeWithPosition.y - height / 2, - }, + const session = sessions.find((s) => s.key === sessionKey) + if (!session) return 0 + + if (session.spawnedBy) { + return getSessionColumn(session.spawnedBy, visited) + 1 } + return 0 + } + + // Assign columns to all sessions + for (const session of sessions) { + const columnIndex = getSessionColumn(session.key) + sessionColumns.set(session.key, { + sessionKey: session.key, + columnIndex, + spawnY: 0, + items: [], + }) + } + + // Group actions by session and sort by timestamp + const actionsBySession = new Map() + for (const action of actions) { + const sessionKey = action.data.sessionKey + if (!sessionKey) continue + const list = actionsBySession.get(sessionKey) ?? [] + list.push(action) + actionsBySession.set(sessionKey, list) + } + for (const [key, list] of actionsBySession) { + list.sort((a, b) => a.data.timestamp - b.data.timestamp) + actionsBySession.set(key, list) + } + + // Group execs by session + const execsBySession = new Map() + for (const exec of execs) { + const sessionKey = exec.data.sessionKey + if (!sessionKey) continue + const list = execsBySession.get(sessionKey) ?? [] + list.push(exec) + execsBySession.set(sessionKey, list) + } + for (const [key, list] of execsBySession) { + list.sort((a, b) => a.data.startedAt - b.data.startedAt) + execsBySession.set(key, list) + } + + // Build items list for each session (session node + actions + execs) + for (const session of sessions) { + const col = sessionColumns.get(session.key) + if (!col) continue + + // Add session node itself + col.items.push({ + nodeId: `session-${session.key}`, + type: 'session', + timestamp: session.lastActivityAt ?? 0, + data: session, + }) + + // Add actions + const sessionActions = actionsBySession.get(session.key) ?? [] + for (const action of sessionActions) { + col.items.push({ + nodeId: `action-${action.id}`, + type: 'action', + timestamp: action.data.timestamp, + data: action.data, + }) + } + + // Add execs + const sessionExecs = execsBySession.get(session.key) ?? [] + for (const exec of sessionExecs) { + col.items.push({ + nodeId: `exec-${exec.id}`, + type: 'exec', + timestamp: exec.data.startedAt, + data: exec.data, + }) + } + + // Sort all items by timestamp (session node first since it's the start) + col.items.sort((a, b) => { + if (a.type === 'session') return -1 + if (b.type === 'session') return 1 + return a.timestamp - b.timestamp + }) + } + + // Calculate spawn Y positions for child sessions + // When a session is spawned, find the Y position of the parent at that time + for (const session of sessions) { + if (!session.spawnedBy) continue + + const parentCol = sessionColumns.get(session.spawnedBy) + const childCol = sessionColumns.get(session.key) + if (!parentCol || !childCol) 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) ?? [] + const childCreationTime = childActions[0]?.data.timestamp ?? session.lastActivityAt ?? Date.now() + + // Count how many items in parent were before this spawn + let parentItemsBeforeSpawn = 0 + for (const item of parentCol.items) { + if (item.type === 'session') { + parentItemsBeforeSpawn++ + continue + } + if (item.timestamp <= childCreationTime) { + parentItemsBeforeSpawn++ + } + } + + // Calculate Y based on parent's item count + childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET + } + + // Position all nodes + const positionedNodes: Node[] = [] + const positionedNodeIds = new Set() + + // Position crab node + if (crabNode) { + positionedNodes.push({ + ...crabNode, + position: { x: CRAB_OFFSET.x, y: CRAB_OFFSET.y }, + }) + positionedNodeIds.add(crabNode.id) + } + + // Track column usage for collision avoidance: columnIndex -> list of {startY, endY} ranges + const columnRanges = new Map>() + + // Get X position for a column (all nodes in same column share same X) + const getColumnX = (columnIndex: number): number => { + return columnIndex * COLUMN_GAP + } + + // Adjust spawn Y to avoid collisions with existing sessions in same column + const adjustSpawnY = (columnIndex: number, desiredY: number, itemCount: number): number => { + const ranges = columnRanges.get(columnIndex) ?? [] + const estimatedHeight = itemCount * (NODE_DIMENSIONS.action.height + ROW_GAP) + MIN_SESSION_GAP + + let adjustedY = desiredY + + // Check for overlaps and shift down if needed + for (const range of ranges) { + // If our desired position overlaps with an existing range + if (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) { + // Shift below this range with minimum gap + adjustedY = range.endY + MIN_SESSION_GAP + } + } + + // Record our range + ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight }) + columnRanges.set(columnIndex, ranges) + + return adjustedY + } + + // Sort sessions by column index (process column 0 first, then 1, etc.) + // This ensures parent sessions are positioned before children + const sortedSessionKeys = Array.from(sessionColumns.keys()).sort((a, b) => { + const colA = sessionColumns.get(a)!.columnIndex + const colB = sessionColumns.get(b)!.columnIndex + if (colA !== colB) return colA - colB + // Within same column, sort by spawn Y (earlier spawns first) + return sessionColumns.get(a)!.spawnY - sessionColumns.get(b)!.spawnY }) - return { nodes: layoutedNodes, edges } + // Position each session's column + for (const sessionKey of sortedSessionKeys) { + const col = sessionColumns.get(sessionKey)! + const columnX = getColumnX(col.columnIndex) + + // Adjust Y position to avoid collisions with other sessions in same column + const adjustedY = adjustSpawnY(col.columnIndex, col.spawnY, col.items.length) + let currentY = adjustedY + + for (const item of col.items) { + const dims = NODE_DIMENSIONS[item.type] + + positionedNodes.push({ + id: item.nodeId, + type: item.type, + position: { x: columnX, y: currentY }, + data: nodeData(item.data), + }) + positionedNodeIds.add(item.nodeId) + + currentY += dims.height + ROW_GAP + } + + // Track max Y for this column + columnOccupancy.set(col.columnIndex, Math.max( + columnOccupancy.get(col.columnIndex) ?? 0, + currentY + )) + } + + // Handle orphan nodes (actions/execs without a session) + let orphanY = Math.max(...Array.from(columnOccupancy.values()), 0) + 100 + for (const node of nodes) { + if (!positionedNodeIds.has(node.id)) { + const dims = NODE_DIMENSIONS[node.type as keyof typeof NODE_DIMENSIONS] ?? { width: 180, height: 80 } + positionedNodes.push({ + ...node, + position: { x: -200, y: orphanY }, + }) + orphanY += dims.height + ROW_GAP + } + } + + return { nodes: positionedNodes, edges } } // Group nodes by session for better visual organization -export function groupNodesBySession( - nodes: Node[] -): Map { +export function groupNodesBySession(nodes: Node[]): Map { const groups = new Map() for (const node of nodes) { diff --git a/src/routes/monitor/index.tsx b/src/routes/monitor/index.tsx index 95794af..88cb487 100644 --- a/src/routes/monitor/index.tsx +++ b/src/routes/monitor/index.tsx @@ -1,17 +1,20 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { createFileRoute, Link } from '@tanstack/react-router' import { useLiveQuery } from '@tanstack/react-db' import { motion } from 'framer-motion' -import { ArrowLeft, Loader2, HardDrive } from 'lucide-react' +import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react' import { trpc } from '~/integrations/trpc/client' import { sessionsCollection, actionsCollection, + execsCollection, upsertSession, addAction, + addExecEvent, updateSessionStatus, clearCollections, hydrateFromServer, + clearCompletedExecs, } from '~/integrations/clawdbot' import { ActionGraph, @@ -83,9 +86,22 @@ function MonitorPage() { // Live queries from TanStack DB collections const sessionsQuery = useLiveQuery(sessionsCollection) const actionsQuery = useLiveQuery(actionsCollection) + const execsQuery = useLiveQuery(execsCollection) const sessions = sessionsQuery.data ?? [] const actions = actionsQuery.data ?? [] + const execs = execsQuery.data ?? [] + + // Count clearable items (completed/failed execs) + const completedCount = useMemo(() => { + return execs.filter(e => e.status === 'completed' || e.status === 'failed').length + }, [execs]) + + // Handler for clearing completed execs + const handleClearCompleted = useCallback(() => { + const count = clearCompletedExecs() + console.log(`[monitor] cleared ${count} completed execs`) + }, []) // Check connection status and persistence on mount @@ -143,10 +159,12 @@ function MonitorPage() { const hydrateFromPersistence = async () => { try { const status = await trpc.clawdbot.persistenceStatus.query() - if (status.sessionCount > 0 || status.actionCount > 0) { + if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) { const data = await trpc.clawdbot.persistenceHydrate.query() - hydrateFromServer(data.sessions, data.actions) - console.log(`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions`) + hydrateFromServer(data.sessions, data.actions, data.execEvents ?? []) + console.log( + `[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions, ${(data.execEvents ?? []).length} exec events` + ) } setPersistenceEnabled(status.enabled) setPersistenceStartedAt(status.startedAt) @@ -331,6 +349,9 @@ function MonitorPage() { if (data.type === 'action' && data.action) { addAction(data.action) } + if (data.type === 'exec' && data.execEvent) { + addExecEvent(data.execEvent) + } }, onError: (err) => { console.error('[monitor] subscription error:', err) @@ -383,6 +404,23 @@ function MonitorPage() { )} + {/* Clear Completed button */} + {completedCount > 0 && ( + + )} + {/* Persistence indicator */}