mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 17:12:05 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84182b2e4 | ||
|
|
80ddf4d821 | ||
|
|
5af254087c | ||
|
|
f9dbd9b0bf | ||
|
|
bddf8b81a6 | ||
|
|
8db30f1f12 | ||
|
|
3c370e3595 | ||
|
|
8d417e2d18 | ||
|
|
aded6fa1e5 | ||
|
|
3c36ba059a | ||
|
|
854329e5fd | ||
|
|
1fc757c732 | ||
|
|
e2922a08fa | ||
|
|
3c29e63d44 | ||
|
|
2ce840b957 | ||
|
|
1e52901b81 | ||
|
|
1c0388c586 | ||
|
|
eabebfd761 | ||
|
|
80c28485f5 | ||
|
|
e690c847fc | ||
|
|
c6990f5d86 | ||
|
|
ad75627e0f | ||
|
|
5e53b2a70c | ||
|
|
78ddc4cea3 | ||
|
|
139f474076 |
@@ -3,3 +3,4 @@
|
||||
|
||||
# Clawdbot gateway auth token
|
||||
CLAWDBOT_API_TOKEN=
|
||||
|
||||
|
||||
+5
-14
@@ -1,6 +1,4 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
@@ -9,22 +7,15 @@ RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# NOTE: TanStack Start server entry produced by `vite build` does not bind a port
|
||||
# on its own in this repo, so we run the Vite dev server in Docker for now.
|
||||
# This makes the published image functional while we figure out a proper prod server.
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
@@ -28,8 +28,10 @@ docker run -d \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
```
|
||||
|
||||
> Note: When running Crabwalk in Docker, the Clawdbot gateway typically runs on the *host*.
|
||||
> Note: When running Crabwalk in Docker, the Moltbot gateway typically runs on the _host_.
|
||||
> Use `CLAWDBOT_URL=ws://host.docker.internal:18789` so the container can connect.
|
||||
> If you're running Moltbot with `bind: loopback` and `tailscale serve` for secure tailnet-only access, you'll need to run the crabwalk container with host networking - replace `p:3000:3000` with `--network host`
|
||||
> This allows the container to reach 127.0.0.1:18789 while maintaining the security benefits of loopback-only binding.
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
@@ -38,6 +40,8 @@ curl -O https://raw.githubusercontent.com/luccast/crabwalk/master/docker-compose
|
||||
CLAWDBOT_API_TOKEN=your-token CLAWDBOT_URL=ws://host.docker.internal:18789 docker-compose up -d
|
||||
```
|
||||
|
||||
> If gateway is `bind: loopback` only, you will need to edit the `docker-compose.yml` to add `network_mode: host`
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
@@ -62,6 +66,12 @@ Find your token in the clawdbot config file:
|
||||
cat ~/.clawdbot/clawdbot.json | rg "gateway\.auth\.token"
|
||||
```
|
||||
|
||||
Or with jq:
|
||||
|
||||
```bash
|
||||
jq '.gateway.auth.token' ~/.clawdbot/clawdbot.json
|
||||
```
|
||||
|
||||
Or copy it directly:
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+1231
-135
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000 --host",
|
||||
"build": "vite build",
|
||||
"start": "node dist/server/server.js"
|
||||
"start": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/db": "^0.5.0",
|
||||
"@tanstack/history": "^1.132.0",
|
||||
"@tanstack/react-db": "^0.1.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
@@ -17,9 +18,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",
|
||||
@@ -28,8 +27,7 @@
|
||||
"superjson": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.24.0",
|
||||
"@tanstack/history": "^1.132.0"
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -38,8 +36,12 @@
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"nitro": "npm:nitro-nightly@^3.0.1-20260128-211656-ae83c97e",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -6,25 +6,41 @@ import {
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
useOnViewportChange,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
type NodeChange,
|
||||
MarkerType,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react'
|
||||
import { LayoutGrid, ArrowRightLeft, ArrowUpDown, Crosshair, Loader2 } from 'lucide-react'
|
||||
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
|
||||
isHydrating?: boolean
|
||||
}
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
function nodeData<T>(data: T): Record<string, unknown> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
const CRAB_NODE_ID = 'crab-origin'
|
||||
@@ -46,6 +62,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,8 +80,10 @@ interface CrabAI {
|
||||
function ActionGraphInner({
|
||||
sessions,
|
||||
actions,
|
||||
execs,
|
||||
selectedSession,
|
||||
onSessionSelect,
|
||||
isHydrating = false,
|
||||
}: ActionGraphProps) {
|
||||
// Crab AI state
|
||||
const crabRef = useRef<CrabAI>({
|
||||
@@ -78,20 +97,50 @@ function ActionGraphInner({
|
||||
|
||||
const prevNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
|
||||
const pinnedPositions = useRef<Map<string, { x: number; y: number }>>(new Map())
|
||||
const animationFrameRef = useRef<number>(undefined)
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
// 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)
|
||||
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 +157,7 @@ function ActionGraphInner({
|
||||
id: `session-${session.key}`,
|
||||
type: 'session',
|
||||
position: { x: 0, y: 0 },
|
||||
data: session as unknown as Record<string, unknown>,
|
||||
data: nodeData(session),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,12 +166,21 @@ function ActionGraphInner({
|
||||
id: `action-${action.id}`,
|
||||
type: 'action',
|
||||
position: { x: 0, y: 0 },
|
||||
data: action as unknown as Record<string, unknown>,
|
||||
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 +190,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<string, MonitorAction[]>()
|
||||
for (const action of visibleActions) {
|
||||
const key = action.sessionKey
|
||||
@@ -152,6 +215,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 +290,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 +300,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 +310,7 @@ function ActionGraphInner({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Subsequent actions connect from previous action
|
||||
const prev = sorted[i - 1]!
|
||||
edges.push({
|
||||
id: `e-${prev.id}-${action.id}`,
|
||||
@@ -223,25 +322,93 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
|
||||
return edges
|
||||
}, [sessions, visibleActions, selectedSession])
|
||||
|
||||
// Apply layout
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||
if (rawNodes.length === 1) {
|
||||
return {
|
||||
nodes: [{ ...rawNodes[0]!, position: { x: 0, y: 0 } }],
|
||||
edges: [],
|
||||
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' },
|
||||
}
|
||||
}
|
||||
}
|
||||
return layoutGraph(rawNodes, rawEdges, {
|
||||
direction: 'TB',
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 80,
|
||||
rankSep: 60,
|
||||
nodeSep: 30,
|
||||
})
|
||||
}, [rawNodes, rawEdges])
|
||||
|
||||
// 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, visibleExecs, selectedSession])
|
||||
|
||||
// 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 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(() => {
|
||||
@@ -262,7 +429,25 @@ function ActionGraphInner({
|
||||
return [...layoutedNodes, chaserNode]
|
||||
}, [])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||
const [nodes, setNodes, rawOnNodesChange] = useNodesState(initialNodes)
|
||||
|
||||
// Intercept node changes to detect drag-end and pin positions
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
for (const change of changes) {
|
||||
if (
|
||||
change.type === 'position' &&
|
||||
'dragging' in change &&
|
||||
change.dragging === false &&
|
||||
change.position
|
||||
) {
|
||||
pinnedPositions.current.set(change.id, { ...change.position })
|
||||
}
|
||||
}
|
||||
rawOnNodesChange(changes)
|
||||
},
|
||||
[rawOnNodesChange]
|
||||
)
|
||||
|
||||
// Handle crab click - jump animation (defined after setNodes)
|
||||
const handleCrabClick = useCallback(() => {
|
||||
@@ -325,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')) {
|
||||
@@ -338,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
|
||||
@@ -357,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(() => {
|
||||
@@ -534,16 +731,22 @@ function ActionGraphInner({
|
||||
}
|
||||
}, [layoutedNodes, setNodes, handleCrabClick])
|
||||
|
||||
// Update layout nodes when they change (preserve chaser)
|
||||
// Update layout nodes when they change (preserve chaser + pinned positions)
|
||||
useEffect(() => {
|
||||
setNodes((nds) => {
|
||||
const pinned = pinnedPositions.current
|
||||
const mergedNodes = layoutedNodes.map((n) => {
|
||||
const pin = pinned.get(n.id)
|
||||
return pin ? { ...n, position: pin } : n
|
||||
})
|
||||
|
||||
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
|
||||
if (chaserNode) {
|
||||
return [...layoutedNodes, chaserNode]
|
||||
return [...mergedNodes, chaserNode]
|
||||
}
|
||||
const crab = crabRef.current
|
||||
return [
|
||||
...layoutedNodes,
|
||||
...mergedNodes,
|
||||
{
|
||||
id: CHASER_CRAB_ID,
|
||||
type: 'chaserCrab',
|
||||
@@ -562,6 +765,18 @@ function ActionGraphInner({
|
||||
setEdges(layoutedEdges)
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges, handleCrabClick])
|
||||
|
||||
// Re-organize: clear pinned positions and re-apply layout
|
||||
const handleReorganize = useCallback(() => {
|
||||
pinnedPositions.current.clear()
|
||||
setNodes((nds) => {
|
||||
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
|
||||
if (chaserNode) {
|
||||
return [...layoutedNodes, chaserNode]
|
||||
}
|
||||
return [...layoutedNodes]
|
||||
})
|
||||
}, [layoutedNodes, setNodes])
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -603,16 +818,76 @@ function ActionGraphInner({
|
||||
<Controls
|
||||
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'))
|
||||
pinnedPositions.current.clear()
|
||||
}}
|
||||
title={layoutDirection === 'LR' ? 'Stack sessions vertically' : 'Spread sessions horizontally'}
|
||||
className="p-1.5 rounded bg-shell-800 border border-shell-700 text-gray-300 hover:bg-shell-700 shadow-lg cursor-pointer"
|
||||
>
|
||||
{layoutDirection === 'LR' ? (
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReorganize}
|
||||
title="Re-organize layout"
|
||||
className="p-1.5 rounded bg-shell-800 border border-shell-700 text-gray-300 hover:bg-shell-700 shadow-lg cursor-pointer"
|
||||
>
|
||||
<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'
|
||||
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)"
|
||||
className="bg-shell-900! border-shell-700!"
|
||||
style={{ backgroundColor: '#0a0a0f' }}
|
||||
style={{ backgroundColor: '#0a0a0f', width: 100, height: 75 }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Bot,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
import type { MonitorAction } from '~/integrations/clawdbot'
|
||||
|
||||
@@ -39,12 +38,11 @@ const stateConfig: Record<
|
||||
}
|
||||
> = {
|
||||
start: {
|
||||
icon: Play,
|
||||
borderColor: 'border-neon-mint',
|
||||
bgColor: 'bg-neon-mint/10',
|
||||
iconColor: 'text-neon-mint',
|
||||
animate: false,
|
||||
label: 'Run Started',
|
||||
icon: Loader2,
|
||||
borderColor: 'border-neon-cyan',
|
||||
bgColor: 'bg-neon-cyan/10',
|
||||
iconColor: 'text-neon-cyan',
|
||||
animate: true,
|
||||
},
|
||||
streaming: {
|
||||
icon: Loader2,
|
||||
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => 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)',
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="bg-shell-600! w-2! h-2! border-shell-800!"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Terminal size={13} className="text-shell-400" />
|
||||
<span className="font-display text-xs font-medium text-gray-300 uppercase tracking-wide">
|
||||
Exec
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
ml-1 px-2 py-0.5 rounded-md border text-[10px] font-console truncate max-w-[220px]
|
||||
border-shell-700 ${status.badgeColor}
|
||||
`}
|
||||
title={data.command}
|
||||
>
|
||||
{data.command}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleCopyPid}
|
||||
className="p-1 rounded hover:bg-shell-700 transition-colors"
|
||||
title={`Copy PID: ${data.pid}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
<StatusIcon
|
||||
size={14}
|
||||
className={`${status.iconColor} ${status.animate ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="font-console text-xs text-shell-500 mb-1.5">
|
||||
<span className="text-crab-600">></span> {formatTime(data.lastActivityAt)}
|
||||
</div>
|
||||
|
||||
<div className="font-console text-xs text-shell-400 mb-1.5 flex gap-2 flex-wrap">
|
||||
<span>
|
||||
<span className="text-shell-500">pid:</span> {data.pid}
|
||||
</span>
|
||||
{data.exitCode != null && (
|
||||
<span>
|
||||
<span className="text-shell-500">exit:</span>{' '}
|
||||
<span className={data.exitCode === 0 ? 'text-neon-mint' : 'text-crab-300'}>
|
||||
{data.exitCode}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{displayDuration && (
|
||||
<span className="text-neon-cyan">{displayDuration}</span>
|
||||
)}
|
||||
{data.status === 'running' && (
|
||||
<span className="text-neon-peach">live</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.outputTruncated && (
|
||||
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
|
||||
output truncated
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOutput && !expanded && (
|
||||
<pre className="font-console text-[11px] text-shell-300 bg-shell-950 border border-shell-800 rounded p-2 overflow-hidden line-clamp-4 whitespace-pre-wrap">
|
||||
{preview || '(no output)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{hasOutput && expanded && (
|
||||
<div className="mt-1.5 border border-shell-800 rounded bg-shell-950/60 max-h-[320px] overflow-auto">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[10px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
|
||||
<span>{status.label}</span>
|
||||
<span>{data.outputs.length} chunks</span>
|
||||
</div>
|
||||
<div className="p-2 flex flex-col gap-1.5">
|
||||
{data.outputs.map((chunk) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
className={`border rounded px-2 py-1 ${streamStyle(chunk.stream)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1 text-[10px] font-console text-shell-500">
|
||||
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
|
||||
{chunk.stream}
|
||||
</span>
|
||||
<span>{formatTime(chunk.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="font-console text-[11px] whitespace-pre-wrap wrap-break-workds">
|
||||
{chunk.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="bg-shell-600! w-2! h-2! border-shell-800!"
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Users, ChevronLeft, ChevronRight, Github } from "lucide-react";
|
||||
import { Users, ChevronLeft, ChevronRight, ChevronDown, Github } from "lucide-react";
|
||||
import { StatusIndicator } from "./StatusIndicator";
|
||||
import type { MonitorSession } from "~/integrations/clawdbot";
|
||||
|
||||
function isSubagent(session: MonitorSession): boolean {
|
||||
return Boolean(session.spawnedBy) || session.platform === "subagent" || session.key.includes("subagent");
|
||||
}
|
||||
|
||||
function XIcon({
|
||||
size = 14,
|
||||
className,
|
||||
@@ -41,6 +45,54 @@ const platformEmoji: Record<string, string> = {
|
||||
slack: "💼",
|
||||
};
|
||||
|
||||
function SubagentItem({
|
||||
session,
|
||||
selected,
|
||||
collapsed,
|
||||
onSelect,
|
||||
}: {
|
||||
session: MonitorSession;
|
||||
selected: boolean;
|
||||
collapsed: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left border-b border-shell-800/50 transition-all duration-150 group ${
|
||||
collapsed ? "p-2" : "py-2 pr-3 pl-6"
|
||||
} ${
|
||||
selected
|
||||
? "bg-neon-cyan/5 border-l-2 border-l-neon-cyan"
|
||||
: "hover:bg-shell-800/30 border-l-2 border-l-transparent"
|
||||
}`}
|
||||
title={collapsed ? "subagent" : undefined}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-sm">🤖</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
subagent
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">🤖</span>
|
||||
<span className="font-console text-[11px] text-shell-400 truncate flex-1 group-hover:text-shell-200">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionList({
|
||||
sessions,
|
||||
selectedKey,
|
||||
@@ -50,10 +102,12 @@ export function SessionList({
|
||||
}: SessionListProps) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [platformFilter, setPlatformFilter] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const platforms = [...new Set(sessions.map((s) => s.platform))];
|
||||
const parentSessions = sessions.filter((s) => !isSubagent(s));
|
||||
const platforms = [...new Set(parentSessions.map((s) => s.platform))];
|
||||
|
||||
const filteredSessions = sessions.filter((session) => {
|
||||
const filteredParents = parentSessions.filter((session) => {
|
||||
const matchesText =
|
||||
!filter ||
|
||||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
@@ -64,12 +118,45 @@ export function SessionList({
|
||||
});
|
||||
|
||||
// Sort: active first, then by lastActivityAt
|
||||
const sortedSessions = [...filteredSessions].sort((a, b) => {
|
||||
const sortedParents = [...filteredParents].sort((a, b) => {
|
||||
if (a.status !== "idle" && b.status === "idle") return -1;
|
||||
if (a.status === "idle" && b.status !== "idle") return 1;
|
||||
return b.lastActivityAt - a.lastActivityAt;
|
||||
});
|
||||
|
||||
// Group subagents by parent key
|
||||
const { subagentsByParent, orphanSubagents } = useMemo(() => {
|
||||
const byParent = new Map<string, MonitorSession[]>();
|
||||
const orphans: MonitorSession[] = [];
|
||||
const parentKeys = new Set(parentSessions.map((s) => s.key));
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!isSubagent(session)) continue;
|
||||
const matchesFilter =
|
||||
!filter ||
|
||||
session.agentId.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
"subagent".includes(filter.toLowerCase());
|
||||
if (!matchesFilter) continue;
|
||||
|
||||
if (session.spawnedBy && parentKeys.has(session.spawnedBy)) {
|
||||
const list = byParent.get(session.spawnedBy) ?? [];
|
||||
list.push(session);
|
||||
byParent.set(session.spawnedBy, list);
|
||||
} else {
|
||||
orphans.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort subagents within each group by activity
|
||||
for (const [key, list] of byParent) {
|
||||
list.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
||||
byParent.set(key, list);
|
||||
}
|
||||
orphans.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
||||
|
||||
return { subagentsByParent: byParent, orphanSubagents: orphans };
|
||||
}, [sessions, parentSessions, filter]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col h-full bg-shell-900 relative"
|
||||
@@ -119,7 +206,7 @@ export function SessionList({
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
!platformFilter
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
@@ -131,7 +218,7 @@ export function SessionList({
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
platformFilter === p
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
@@ -149,64 +236,129 @@ export function SessionList({
|
||||
{/* Session list */}
|
||||
<div className="relative flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedSessions.map((session) => (
|
||||
<motion.button
|
||||
key={session.key}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
|
||||
selectedKey === session.key
|
||||
? "bg-crab-900/20 border-l-2 border-l-crab-500"
|
||||
: "hover:bg-shell-800/50 border-l-2 border-l-transparent"
|
||||
}`}
|
||||
title={
|
||||
collapsed
|
||||
? `${session.recipient} (${session.platform})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
// Collapsed view: just icon and status
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-lg">
|
||||
{platformEmoji[session.platform] || "📱"}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
// Expanded view
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{sortedParents.map((session) => (
|
||||
<div key={session.key}>
|
||||
<motion.button
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
|
||||
selectedKey === session.key
|
||||
? "bg-crab-900/20 border-l-2 border-l-crab-500"
|
||||
: "hover:bg-shell-800/50 border-l-2 border-l-transparent"
|
||||
}`}
|
||||
title={
|
||||
collapsed
|
||||
? `${session.recipient} (${session.platform})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-lg">
|
||||
{platformEmoji[session.platform] || "📱"}
|
||||
</span>
|
||||
<span className="font-display text-xs font-medium text-gray-200 truncate flex-1 uppercase tracking-wide group-hover:text-white">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 truncate flex-1">
|
||||
{session.agentId}
|
||||
</span>
|
||||
{session.isGroup && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[11px] text-shell-400">
|
||||
<Users size={10} />
|
||||
group
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-lg">
|
||||
{platformEmoji[session.platform] || "📱"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="font-display text-xs font-medium text-gray-200 truncate flex-1 uppercase tracking-wide group-hover:text-white">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[11px] text-shell-500 truncate flex-1">
|
||||
{session.agentId}
|
||||
</span>
|
||||
{session.isGroup && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[11px] text-shell-400">
|
||||
<Users size={10} />
|
||||
group
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{/* Nested subagents */}
|
||||
{(() => {
|
||||
const subs = subagentsByParent.get(session.key);
|
||||
if (!subs?.length) return null;
|
||||
const isGroupCollapsed = collapsedGroups.has(session.key);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(session.key)) next.delete(session.key);
|
||||
else next.add(session.key);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full border-b border-shell-800/50 transition-all ${
|
||||
collapsed ? "p-2 justify-center" : "px-4 py-1.5 text-left"
|
||||
} flex items-center gap-1.5 text-xs font-display uppercase tracking-widest text-shell-500 hover:text-shell-300 hover:bg-shell-800/30`}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span>{subs.length} subagent{subs.length > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</button>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isGroupCollapsed ? 0 : "auto",
|
||||
opacity: isGroupCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{subs.map((sub) => (
|
||||
<SubagentItem
|
||||
key={sub.key}
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Orphan subagents */}
|
||||
{orphanSubagents.map((sub) => (
|
||||
<SubagentItem
|
||||
key={sub.key}
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedSessions.length === 0 && !collapsed && (
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
|
||||
<div className="p-6 text-center">
|
||||
<div className="font-console text-xs text-shell-500">
|
||||
<span className="text-crab-600">></span> no sessions found
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<motion.div
|
||||
@@ -32,22 +69,57 @@ export const SessionNode = memo(function SessionNode({
|
||||
bg-shell-900 text-white
|
||||
${selected ? 'border-crab-500' : 'border-shell-600'}
|
||||
${data.status === 'thinking' ? 'border-neon-peach' : ''}
|
||||
${isSubagent ? 'border-neon-cyan border-opacity-50' : ''}
|
||||
transition-all duration-150 hover:bg-shell-800
|
||||
`}
|
||||
style={{
|
||||
boxShadow: selected
|
||||
? '0 0 20px rgba(239, 68, 68, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
: isSubagent
|
||||
? '0 0 12px rgba(0, 255, 213, 0.2), 0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
|
||||
<Handle type="target" id="spawn-target" position={Position.Left} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">{platformIcon}</span>
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-wide text-gray-200">
|
||||
{data.platform}
|
||||
{displayPlatform}
|
||||
</span>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className="p-1 rounded hover:bg-shell-700 transition-colors"
|
||||
title={`Copy key: ${data.key}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -65,7 +137,15 @@ export const SessionNode = memo(function SessionNode({
|
||||
<span className="text-crab-600">></span> {data.agentId}
|
||||
</div>
|
||||
|
||||
{relativeTime && (
|
||||
<div className="flex items-center gap-1 mt-2 font-console text-[10px] text-shell-500">
|
||||
<Clock size={10} className="text-shell-600" />
|
||||
<span>{relativeTime}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
|
||||
<Handle type="source" id="spawn-source" position={Position.Right} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square } from 'lucide-react'
|
||||
import { Settings, X, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square, CloudDownload } from 'lucide-react'
|
||||
import { version } from '../../../package.json'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
connected: boolean
|
||||
@@ -137,31 +138,6 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historical mode toggle */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<History size={18} className="text-shell-500" />
|
||||
<span className="font-display text-sm font-medium text-gray-200 uppercase tracking-wide">
|
||||
Historical Mode
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-4">
|
||||
<span className="text-crab-600">></span> load past sessions on connect
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => onHistoricalModeChange(!historicalMode)}
|
||||
className={`w-full px-4 py-2 font-display text-xs uppercase tracking-wide rounded-lg transition-all ${
|
||||
historicalMode
|
||||
? 'bg-crab-600 text-white box-glow-red'
|
||||
: 'bg-shell-800 text-gray-400 hover:bg-shell-700'
|
||||
}`}
|
||||
>
|
||||
{historicalMode ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Debug mode toggle */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -243,6 +219,31 @@ export function SettingsPanel({
|
||||
<Trash2 size={12} />
|
||||
Clear Stored Data
|
||||
</button>
|
||||
|
||||
{/* Gateway sync sub-option */}
|
||||
<div className="mt-4 pt-4 border-t border-shell-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudDownload size={14} className="text-shell-500" />
|
||||
<span className="font-display text-xs text-gray-300 uppercase tracking-wide">
|
||||
Sync Gateway (24h)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onHistoricalModeChange(!historicalMode)}
|
||||
className={`px-3 py-1 font-display text-[10px] uppercase tracking-wide rounded transition-all ${
|
||||
historicalMode
|
||||
? 'bg-neon-cyan/20 text-neon-cyan'
|
||||
: 'bg-shell-800 text-gray-500 hover:bg-shell-700'
|
||||
}`}
|
||||
>
|
||||
{historicalMode ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-console text-[10px] text-shell-500">
|
||||
<span className="text-crab-600">></span> fetch 24h of sessions from gateway on refresh
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log collection */}
|
||||
@@ -322,7 +323,7 @@ export function SettingsPanel({
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
crabwalk v1.0.1
|
||||
crabwalk v{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
|
||||
import type { MonitorSession, 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<string, string>()
|
||||
|
||||
// Track recent parent session actions with precise timestamps
|
||||
// Stores the last few action timestamps per parent session
|
||||
const parentActionHistory = new Map<string, number[]>()
|
||||
const MAX_ACTION_HISTORY = 10
|
||||
|
||||
// Time window for spawn inference
|
||||
const SPAWN_INFERENCE_WINDOW_MS = 10000
|
||||
|
||||
function isSubagentSession(key: string): boolean {
|
||||
return key.includes('subagent')
|
||||
}
|
||||
|
||||
function isParentSession(key: string): boolean {
|
||||
return !isSubagentSession(key) && !key.includes('lifecycle')
|
||||
}
|
||||
|
||||
// Track an action on a parent session with its timestamp
|
||||
function trackParentAction(sessionKey: string, timestamp?: number) {
|
||||
if (!isParentSession(sessionKey)) return
|
||||
const ts = timestamp ?? Date.now()
|
||||
|
||||
let history = parentActionHistory.get(sessionKey)
|
||||
if (!history) {
|
||||
history = []
|
||||
parentActionHistory.set(sessionKey, history)
|
||||
}
|
||||
|
||||
history.push(ts)
|
||||
|
||||
// Keep only recent entries
|
||||
if (history.length > MAX_ACTION_HISTORY) {
|
||||
history.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// Infer which parent session spawned this subagent
|
||||
// Finds the parent with the most recent action before the subagent's timestamp
|
||||
function inferSpawnedBy(subagentKey: string, timestamp?: number): string | undefined {
|
||||
if (!isSubagentSession(subagentKey)) return undefined
|
||||
|
||||
const subagentTime = timestamp ?? Date.now()
|
||||
const cutoff = subagentTime - SPAWN_INFERENCE_WINDOW_MS
|
||||
|
||||
let bestParent: string | undefined
|
||||
let bestTime = 0
|
||||
|
||||
for (const [parentKey, history] of parentActionHistory) {
|
||||
// Find the most recent action from this parent that's before the subagent time
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const actionTime = history[i]!
|
||||
// Must be before subagent appeared and within window
|
||||
if (actionTime <= subagentTime && actionTime >= cutoff) {
|
||||
if (actionTime > bestTime) {
|
||||
bestTime = actionTime
|
||||
bestParent = parentKey
|
||||
}
|
||||
break // Found the most recent valid action for this parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestParent) {
|
||||
console.log(`[spawn] linked ${subagentKey} to ${bestParent} (action ${subagentTime - bestTime}ms before)`)
|
||||
} else {
|
||||
console.log(`[spawn] could not infer parent for ${subagentKey}`)
|
||||
}
|
||||
|
||||
return bestParent
|
||||
}
|
||||
|
||||
export const sessionsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorSession>({
|
||||
id: 'clawdbot-sessions',
|
||||
@@ -18,28 +96,144 @@ export const actionsCollection = createCollection(
|
||||
})
|
||||
)
|
||||
|
||||
export const execsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorExecProcess>({
|
||||
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) {
|
||||
const existing = sessionsCollection.state.get(session.key)
|
||||
|
||||
if (existing) {
|
||||
// Preserve existing spawnedBy - never overwrite once set
|
||||
const preservedSpawnedBy = existing.spawnedBy
|
||||
sessionsCollection.update(session.key, (draft) => {
|
||||
Object.assign(draft, session)
|
||||
if (preservedSpawnedBy) {
|
||||
draft.spawnedBy = preservedSpawnedBy
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sessionsCollection.insert(session)
|
||||
// New session - infer spawnedBy for subagents if not provided
|
||||
let spawnedBy = session.spawnedBy
|
||||
if (!spawnedBy && isSubagentSession(session.key)) {
|
||||
spawnedBy = inferSpawnedBy(session.key, session.lastActivityAt)
|
||||
}
|
||||
sessionsCollection.insert({
|
||||
...session,
|
||||
spawnedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to add or update action
|
||||
// Aggregation strategy per run:
|
||||
// - start: one node per runId (appears immediately)
|
||||
// - streaming: aggregate all deltas into one node (content updates)
|
||||
// - complete: updates streaming node with final state & metadata
|
||||
// Unified node lifecycle per runId:
|
||||
// - start/streaming/complete/error/aborted all update the same node
|
||||
// - Node type reflects current state in the lifecycle
|
||||
// - tool_call/tool_result: separate nodes
|
||||
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 parent session actions for spawn inference
|
||||
if (isParentSession(action.sessionKey)) {
|
||||
trackParentAction(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve sessionKey: use mapped value if action has lifecycle/invalid key
|
||||
@@ -48,73 +242,49 @@ export function addAction(action: MonitorAction) {
|
||||
sessionKey = runSessionMap.get(action.runId) || sessionKey
|
||||
}
|
||||
|
||||
// Handle 'start' type - create dedicated start node
|
||||
if (action.type === 'start') {
|
||||
const startId = `${action.runId}-start`
|
||||
const existing = actionsCollection.state.get(startId)
|
||||
if (!existing) {
|
||||
actionsCollection.insert({
|
||||
...action,
|
||||
id: startId,
|
||||
sessionKey,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Unified node lifecycle: start → streaming → complete/error/aborted
|
||||
// All states for the same runId share one node ID
|
||||
const actionNodeId = `${action.runId}-action`
|
||||
|
||||
// Handle start, streaming, complete, error, aborted - all update the same node
|
||||
if (['start', 'streaming', 'complete', 'error', 'aborted'].includes(action.type)) {
|
||||
const existing = actionsCollection.state.get(actionNodeId)
|
||||
|
||||
// For streaming, aggregate into single node per runId
|
||||
if (action.type === 'streaming') {
|
||||
const streamingId = `${action.runId}-stream`
|
||||
const existing = actionsCollection.state.get(streamingId)
|
||||
if (existing) {
|
||||
// Replace content (gateway sends cumulative text, not incremental deltas)
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
if (action.content) {
|
||||
draft.content = action.content
|
||||
}
|
||||
draft.seq = action.seq
|
||||
draft.timestamp = action.timestamp
|
||||
if (sessionKey && sessionKey !== 'lifecycle') {
|
||||
draft.sessionKey = sessionKey
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Create new streaming action
|
||||
actionsCollection.insert({
|
||||
...action,
|
||||
id: streamingId,
|
||||
sessionKey,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For complete/error/aborted, update the streaming action
|
||||
if (action.type === 'complete' || action.type === 'error' || action.type === 'aborted') {
|
||||
const streamingId = `${action.runId}-stream`
|
||||
const streaming = actionsCollection.state.get(streamingId)
|
||||
if (streaming) {
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
actionsCollection.update(actionNodeId, (draft) => {
|
||||
// Always update type to reflect current state
|
||||
draft.type = action.type
|
||||
draft.seq = action.seq
|
||||
draft.timestamp = action.timestamp
|
||||
|
||||
if (sessionKey && sessionKey !== 'lifecycle') {
|
||||
draft.sessionKey = sessionKey
|
||||
}
|
||||
// Copy metadata from complete event
|
||||
|
||||
// Update content if present
|
||||
if (action.content) {
|
||||
draft.content = action.content
|
||||
}
|
||||
|
||||
// Copy metadata from complete/error events
|
||||
if (action.inputTokens !== undefined) draft.inputTokens = action.inputTokens
|
||||
if (action.outputTokens !== undefined) draft.outputTokens = action.outputTokens
|
||||
if (action.stopReason) draft.stopReason = action.stopReason
|
||||
if (action.endedAt) draft.endedAt = action.endedAt
|
||||
|
||||
// Calculate duration if we have both timestamps
|
||||
if (draft.startedAt && action.endedAt) {
|
||||
draft.duration = action.endedAt - draft.startedAt
|
||||
}
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Create new action node
|
||||
actionsCollection.insert({
|
||||
...action,
|
||||
id: actionNodeId,
|
||||
sessionKey,
|
||||
})
|
||||
}
|
||||
// No streaming action found, create as-is with complete state
|
||||
actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,16 +295,123 @@ 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,
|
||||
status: MonitorSession['status']
|
||||
) {
|
||||
const now = Date.now()
|
||||
const session = sessionsCollection.state.get(key)
|
||||
if (session) {
|
||||
sessionsCollection.update(key, (draft) => {
|
||||
draft.status = status
|
||||
draft.lastActivityAt = Date.now()
|
||||
draft.lastActivityAt = now
|
||||
})
|
||||
} else if (isSubagentSession(key)) {
|
||||
// New subagent session via status update - create with inferred parent
|
||||
const spawnedBy = inferSpawnedBy(key, now)
|
||||
const parsed = parseSessionKey(key)
|
||||
sessionsCollection.insert({
|
||||
key,
|
||||
agentId: parsed.agentId,
|
||||
platform: parsed.platform,
|
||||
recipient: parsed.recipient,
|
||||
isGroup: parsed.isGroup,
|
||||
lastActivityAt: now,
|
||||
status,
|
||||
spawnedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -151,31 +428,108 @@ export function updateSession(key: string, update: Partial<MonitorSession>) {
|
||||
|
||||
// Clear all data
|
||||
export function clearCollections() {
|
||||
runSessionMap.clear()
|
||||
parentActionHistory.clear()
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
sessionsCollection.delete(session.key)
|
||||
}
|
||||
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()
|
||||
|
||||
// Insert all sessions
|
||||
for (const session of sessions) {
|
||||
sessionsCollection.insert(session)
|
||||
// Sort actions by timestamp for replay
|
||||
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// First pass: build parent action history for spawn inference
|
||||
for (const action of sortedActions) {
|
||||
if (action.sessionKey && isParentSession(action.sessionKey)) {
|
||||
trackParentAction(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay actions through addAction to apply aggregation logic
|
||||
// Sort by timestamp to ensure correct order
|
||||
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
// Also track parent sessions by their lastActivityAt
|
||||
for (const session of sessions) {
|
||||
if (isParentSession(session.key)) {
|
||||
trackParentAction(session.key, session.lastActivityAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert all sessions - subagents will get inferred spawnedBy from Task tool calls
|
||||
for (const session of sessions) {
|
||||
if (isSubagentSession(session.key)) {
|
||||
const spawnedBy = session.spawnedBy || inferSpawnedBy(session.key, session.lastActivityAt)
|
||||
sessionsCollection.insert({ ...session, spawnedBy })
|
||||
} else {
|
||||
sessionsCollection.insert(session)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay actions through addAction for aggregation
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ import type {
|
||||
EventFrame,
|
||||
ChatEvent,
|
||||
AgentEvent,
|
||||
ExecStartedEvent,
|
||||
ExecOutputEvent,
|
||||
ExecCompletedEvent,
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecEvent,
|
||||
SessionInfo,
|
||||
} from './protocol'
|
||||
import { parseSessionKey } from './protocol'
|
||||
@@ -18,6 +22,7 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
|
||||
isGroup: parsed.isGroup,
|
||||
lastActivityAt: info.lastActivityAt,
|
||||
status: 'idle',
|
||||
spawnedBy: info.spawnedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,11 +113,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
if (event.stream === 'lifecycle') {
|
||||
if (data.phase === 'start') {
|
||||
type = 'start'
|
||||
content = 'Run started'
|
||||
// No placeholder content - will show "Run Started" label from UI
|
||||
startedAt = typeof data.startedAt === 'number' ? data.startedAt : event.ts
|
||||
} else if (data.phase === 'end') {
|
||||
type = 'complete'
|
||||
content = 'Run completed'
|
||||
// No placeholder content - preserve streamed content from assistant events
|
||||
endedAt = typeof data.endedAt === 'number' ? data.endedAt : event.ts
|
||||
}
|
||||
} else if (data.type === 'tool_use') {
|
||||
@@ -123,7 +128,8 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
} else if (data.type === 'tool_result') {
|
||||
type = 'tool_result'
|
||||
content = String(data.content || '')
|
||||
} else if (data.type === 'text') {
|
||||
} else if (data.type === 'text' || typeof data.text === 'string') {
|
||||
// Handle both { type: 'text', text: '...' } and assistant stream { text: '...', delta: '...' }
|
||||
type = 'streaming'
|
||||
content = String(data.text || '')
|
||||
}
|
||||
@@ -147,7 +153,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
|
||||
export function parseEventFrame(
|
||||
frame: EventFrame
|
||||
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
|
||||
): {
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
execEvent?: MonitorExecEvent
|
||||
} | null {
|
||||
// Skip system events
|
||||
if (frame.event === 'health' || frame.event === 'tick') {
|
||||
return null
|
||||
@@ -168,12 +178,7 @@ export function parseEventFrame(
|
||||
if (frame.event === 'agent' && frame.payload) {
|
||||
const agentEvent = frame.payload as AgentEvent
|
||||
|
||||
// Skip assistant stream - it duplicates chat events
|
||||
if (agentEvent.stream === 'assistant') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only process lifecycle events (start/end markers)
|
||||
// Process lifecycle events (start/end markers)
|
||||
if (agentEvent.stream === 'lifecycle') {
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
@@ -185,8 +190,123 @@ export function parseEventFrame(
|
||||
}
|
||||
}
|
||||
|
||||
// Process assistant stream for streaming content
|
||||
// Assistant events have { text: "cumulative", delta: "incremental" } structure
|
||||
if (agentEvent.stream === 'assistant' && typeof agentEvent.data?.text === 'string') {
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: 'thinking',
|
||||
lastActivityAt: Date.now(),
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool events (tool_use, tool_result)
|
||||
if (agentEvent.data?.type === 'tool_use' || agentEvent.data?.type === 'tool_result') {
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: 'thinking',
|
||||
lastActivityAt: Date.now(),
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, MonitorSession> = 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -100,6 +126,8 @@ export interface SessionInfo {
|
||||
lastActivityAt: number
|
||||
messageCount: number
|
||||
lastMessage?: unknown
|
||||
/** Session key of the parent session that spawned this subagent session. */
|
||||
spawnedBy?: string
|
||||
}
|
||||
|
||||
// App-level types
|
||||
@@ -111,6 +139,8 @@ export interface MonitorSession {
|
||||
isGroup: boolean
|
||||
lastActivityAt: number
|
||||
status: 'idle' | 'active' | 'thinking'
|
||||
/** Session key of the parent session that spawned this subagent session. */
|
||||
spawnedBy?: string
|
||||
}
|
||||
|
||||
export interface MonitorAction {
|
||||
@@ -133,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
|
||||
|
||||
@@ -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<MonitorSession>
|
||||
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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+364
-38
@@ -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<T>(data: T): Record<string, unknown> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction?: 'TB' | 'LR' | 'BT' | 'RL'
|
||||
@@ -9,60 +18,377 @@ 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 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
|
||||
rootIndex: number // Which root tree this session belongs to (for horizontal mode)
|
||||
spawnY: number // Y position where this session was spawned from parent
|
||||
items: Array<{
|
||||
nodeId: string
|
||||
type: 'session' | 'action' | 'exec'
|
||||
timestamp: number
|
||||
data: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout algorithm:
|
||||
* - Vertical (TB): All roots in column 0, subagents spawn to the right based on depth
|
||||
* - Horizontal (LR): Each root gets its own column group, subagents spawn further right
|
||||
* - Subagents positioned at the Y-level where they were spawned (timeline style)
|
||||
*/
|
||||
export function layoutGraph(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: LayoutOptions = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const {
|
||||
direction = 'TB',
|
||||
nodeWidth = 200,
|
||||
nodeHeight = 80,
|
||||
rankSep = 80,
|
||||
nodeSep = 40,
|
||||
} = options
|
||||
const direction = options.direction ?? 'LR'
|
||||
const isHorizontal = direction === 'LR' || direction === 'RL'
|
||||
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
|
||||
// Build session hierarchy and columns
|
||||
const sessions = nodes
|
||||
.filter((n) => n.type === 'session')
|
||||
.map((n) => n.data as unknown as MonitorSession)
|
||||
|
||||
// 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 actions = nodes
|
||||
.filter((n) => n.type === 'action')
|
||||
.map((n) => ({ id: n.id.replace('action-', ''), data: n.data as unknown as MonitorAction }))
|
||||
|
||||
// Add edges
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target)
|
||||
}
|
||||
const execs = nodes
|
||||
.filter((n) => n.type === 'exec')
|
||||
.map((n) => ({ id: n.id.replace('exec-', ''), data: n.data as unknown as MonitorExecProcess }))
|
||||
|
||||
// Run layout
|
||||
dagre.layout(g)
|
||||
const crabNode = nodes.find((n) => n.type === 'crab')
|
||||
|
||||
// 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
|
||||
// Build session column map - which column is each session in?
|
||||
const sessionColumns = new Map<string, SessionColumn>()
|
||||
const columnOccupancy = new Map<number, number>() // columnIndex -> maxY used
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - width / 2,
|
||||
y: nodeWithPosition.y - height / 2,
|
||||
},
|
||||
// Find root sessions and build root index map
|
||||
const rootSessions: MonitorSession[] = []
|
||||
const sessionToRoot = new Map<string, number>() // sessionKey -> rootIndex
|
||||
|
||||
// First identify all roots
|
||||
for (const session of sessions) {
|
||||
if (!session.spawnedBy || !sessions.find(s => s.key === session.spawnedBy)) {
|
||||
rootSessions.push(session)
|
||||
}
|
||||
}
|
||||
// Sort by session key for stable ordering - lastActivityAt changes during streaming
|
||||
// which would cause nodes to swap positions
|
||||
rootSessions.sort((a, b) => a.key.localeCompare(b.key))
|
||||
|
||||
// Assign root index to each root
|
||||
rootSessions.forEach((root, idx) => sessionToRoot.set(root.key, idx))
|
||||
|
||||
// Find root for any session by walking up the spawn chain
|
||||
const findRootIndex = (sessionKey: string, visited = new Set<string>()): number => {
|
||||
if (visited.has(sessionKey)) return 0
|
||||
visited.add(sessionKey)
|
||||
|
||||
if (sessionToRoot.has(sessionKey)) {
|
||||
return sessionToRoot.get(sessionKey)!
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => s.key === sessionKey)
|
||||
if (!session || !session.spawnedBy) return 0
|
||||
|
||||
const rootIdx = findRootIndex(session.spawnedBy, visited)
|
||||
sessionToRoot.set(sessionKey, rootIdx)
|
||||
return rootIdx
|
||||
}
|
||||
|
||||
// Determine column for each session based on spawn hierarchy
|
||||
const getSessionDepth = (sessionKey: string, visited = new Set<string>()): number => {
|
||||
if (visited.has(sessionKey)) return 0
|
||||
visited.add(sessionKey)
|
||||
|
||||
const session = sessions.find((s) => s.key === sessionKey)
|
||||
if (!session) return 0
|
||||
|
||||
if (session.spawnedBy) {
|
||||
return getSessionDepth(session.spawnedBy, visited) + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Assign columns to all sessions
|
||||
for (const session of sessions) {
|
||||
const depth = getSessionDepth(session.key)
|
||||
const rootIndex = findRootIndex(session.key)
|
||||
sessionColumns.set(session.key, {
|
||||
sessionKey: session.key,
|
||||
columnIndex: depth,
|
||||
rootIndex,
|
||||
spawnY: depth === 0 ? ROOT_START_Y : 0, // Root sessions start below crab
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Group actions by session and sort by timestamp
|
||||
const actionsBySession = new Map<string, typeof actions>()
|
||||
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<string, typeof execs>()
|
||||
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
|
||||
// Cache these values so they don't jitter as parent actions accumulate
|
||||
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
|
||||
|
||||
// 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) ?? []
|
||||
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 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
|
||||
const positionedNodes: Node[] = []
|
||||
const positionedNodeIds = new Set<string>()
|
||||
|
||||
// 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
|
||||
// In horizontal mode, we track per (rootIndex, columnIndex)
|
||||
// In vertical mode, we track per columnIndex only
|
||||
const columnRanges = new Map<string, Array<{ startY: number; endY: number }>>()
|
||||
|
||||
const getColumnKey = (rootIndex: number, columnIndex: number): string => {
|
||||
return isHorizontal ? `${rootIndex}-${columnIndex}` : `${columnIndex}`
|
||||
}
|
||||
|
||||
// Get X position for a session
|
||||
const getColumnX = (rootIndex: number, columnIndex: number): number => {
|
||||
if (isHorizontal) {
|
||||
// Each root tree gets its own horizontal space
|
||||
// Root at rootIndex * (maxDepth * COLUMN_GAP + ROOT_HORIZONTAL_GAP)
|
||||
// Plus columnIndex * COLUMN_GAP for depth within tree
|
||||
const maxDepth = Math.max(...Array.from(sessionColumns.values()).map(c => c.columnIndex)) + 1
|
||||
const treeWidth = maxDepth * COLUMN_GAP
|
||||
return rootIndex * (treeWidth + ROOT_HORIZONTAL_GAP) + columnIndex * COLUMN_GAP
|
||||
} else {
|
||||
// Vertical: all sessions at same depth share X
|
||||
return columnIndex * COLUMN_GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust spawn Y to avoid collisions with existing sessions in same column
|
||||
const adjustSpawnY = (rootIndex: number, columnIndex: number, desiredY: number, itemCount: number): number => {
|
||||
const key = getColumnKey(rootIndex, columnIndex)
|
||||
const ranges = columnRanges.get(key) ?? []
|
||||
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 (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) {
|
||||
adjustedY = range.endY + MIN_SESSION_GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Record our range
|
||||
ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight })
|
||||
columnRanges.set(key, 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)!
|
||||
const colB = sessionColumns.get(b)!
|
||||
// First by root index (in horizontal mode)
|
||||
if (isHorizontal && colA.rootIndex !== colB.rootIndex) {
|
||||
return colA.rootIndex - colB.rootIndex
|
||||
}
|
||||
// Then by column index (depth)
|
||||
if (colA.columnIndex !== colB.columnIndex) {
|
||||
return colA.columnIndex - colB.columnIndex
|
||||
}
|
||||
// Within same column, sort by spawn Y (earlier spawns first)
|
||||
return colA.spawnY - colB.spawnY
|
||||
})
|
||||
|
||||
return { nodes: layoutedNodes, edges }
|
||||
// Position each session's column
|
||||
for (const sessionKey of sortedSessionKeys) {
|
||||
const col = sessionColumns.get(sessionKey)!
|
||||
const columnX = getColumnX(col.rootIndex, col.columnIndex)
|
||||
|
||||
// Adjust Y position to avoid collisions with other sessions in same column
|
||||
const adjustedY = adjustSpawnY(col.rootIndex, 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// Group nodes by session for better visual organization
|
||||
export function groupNodesBySession(
|
||||
nodes: Node[]
|
||||
): Map<string, Node[]> {
|
||||
export function groupNodesBySession(nodes: Node[]): Map<string, Node[]> {
|
||||
const groups = new Map<string, Node[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Github } from 'lucide-react'
|
||||
import { version } from '../../package.json'
|
||||
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
|
||||
|
||||
function XIcon({ size = 14, className }: { size?: number; className?: string }) {
|
||||
@@ -165,7 +166,7 @@ function Home() {
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
|
||||
<span className="font-console font-bold text-[11px] uppercase text-shell-500">
|
||||
system online • v1.0.1
|
||||
system online • v{version}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -80,12 +83,28 @@ 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)
|
||||
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,16 +162,21 @@ 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) {
|
||||
setIsHydrating(true)
|
||||
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`
|
||||
)
|
||||
setIsHydrating(false)
|
||||
}
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch (e) {
|
||||
setIsHydrating(false)
|
||||
console.error('Failed to hydrate:', e)
|
||||
}
|
||||
}
|
||||
@@ -331,6 +355,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 +410,23 @@ function MonitorPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Clear Completed button */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all bg-shell-800/50 hover:bg-crab-900/50 hover:border-crab-700/50 border border-transparent group"
|
||||
title={`Clear ${completedCount} completed item${completedCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Trash2
|
||||
size={14}
|
||||
className="text-shell-400 group-hover:text-crab-400 transition-colors"
|
||||
/>
|
||||
<span className="font-console text-xs text-shell-400 group-hover:text-crab-400 transition-colors">
|
||||
{completedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Persistence indicator */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
@@ -458,8 +502,10 @@ function MonitorPage() {
|
||||
<ActionGraph
|
||||
sessions={sessions}
|
||||
actions={actions}
|
||||
execs={execs}
|
||||
selectedSession={selectedSession}
|
||||
onSessionSelect={setSelectedSession}
|
||||
isHydrating={isHydrating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+16
-14
@@ -1,16 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite';
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
|
||||
import { nitro } from 'nitro/vite';
|
||||
import viteReact from '@vitejs/plugin-react';
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
viteTsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
plugins: [
|
||||
viteTsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
nitro(),
|
||||
viteReact(),
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user