mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
- Simplified styles in the ActionGraph and ActionNode components by removing unnecessary border styles for a cleaner look. - Updated ActionNode to improve text sizing for better readability and consistency. - Enhanced SettingsPanel by streamlining button styles and removing redundant border properties, improving overall UI consistency. - Adjusted layout properties in the SessionList component to enhance responsiveness and visual appeal.
630 lines
19 KiB
TypeScript
630 lines
19 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import {
|
|
ReactFlow,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
useNodesState,
|
|
useEdgesState,
|
|
type Node,
|
|
type Edge,
|
|
type NodeTypes,
|
|
MarkerType,
|
|
ReactFlowProvider,
|
|
} from '@xyflow/react'
|
|
import '@xyflow/react/dist/style.css'
|
|
import { SessionNode } from './SessionNode'
|
|
import { ActionNode } from './ActionNode'
|
|
import { CrabNode } from './CrabNode'
|
|
import { ChaserCrabNode, type ChaserCrabState } from './ChaserCrabNode'
|
|
import { layoutGraph } from '~/lib/graph-layout'
|
|
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
|
|
|
|
interface ActionGraphProps {
|
|
sessions: MonitorSession[]
|
|
actions: MonitorAction[]
|
|
selectedSession: string | null
|
|
onSessionSelect: (key: string | null) => void
|
|
}
|
|
|
|
const CRAB_NODE_ID = 'crab-origin'
|
|
const CHASER_CRAB_ID = 'chaser-crab'
|
|
|
|
// Crab behavior constants - tuned for 10fps crab-like movement
|
|
const STEP_INTERVAL = 100 // ms between steps (matches 10fps animation)
|
|
const STEP_SIZE = 8 // pixels per step
|
|
const WANDER_STEP_SIZE = 5
|
|
const ATTACK_DISTANCE = 30
|
|
const ATTACK_CHANCE = 0.5
|
|
const WANDER_RADIUS = 50
|
|
const IDLE_PAUSE_MIN = 800 // min ms to pause when idle
|
|
const IDLE_PAUSE_MAX = 3000 // max ms to pause when idle
|
|
const WANDER_CHANCE = 0.3 // 30% chance to wander after idle pause
|
|
const SIDEWAYS_DRIFT = 0.4 // crabs scuttle sideways
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const nodeTypes: NodeTypes = {
|
|
session: SessionNode as any,
|
|
action: ActionNode as any,
|
|
crab: CrabNode as any,
|
|
chaserCrab: ChaserCrabNode as any,
|
|
}
|
|
|
|
interface CrabAI {
|
|
position: { x: number; y: number }
|
|
target: { x: number; y: number; nodeId?: string } | null
|
|
state: ChaserCrabState
|
|
facingLeft: boolean
|
|
lastStepTime: number
|
|
idleUntil: number // timestamp when idle pause ends
|
|
}
|
|
|
|
// Inner component that uses ReactFlow hooks
|
|
function ActionGraphInner({
|
|
sessions,
|
|
actions,
|
|
selectedSession,
|
|
onSessionSelect,
|
|
}: ActionGraphProps) {
|
|
// Crab AI state
|
|
const crabRef = useRef<CrabAI>({
|
|
position: { x: 50, y: 50 },
|
|
target: null,
|
|
state: 'idle',
|
|
facingLeft: false,
|
|
lastStepTime: 0,
|
|
idleUntil: 0,
|
|
})
|
|
|
|
const prevNodeIdsRef = useRef<Set<string>>(new Set())
|
|
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
|
|
const animationFrameRef = useRef<number>(undefined)
|
|
const timeoutRef = useRef<NodeJS.Timeout>(undefined)
|
|
|
|
// 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])
|
|
|
|
// Build nodes
|
|
const rawNodes = useMemo(() => {
|
|
const nodes: Node[] = []
|
|
|
|
const hasActivity = sessions.length > 0 || visibleActions.length > 0
|
|
nodes.push({
|
|
id: CRAB_NODE_ID,
|
|
type: 'crab',
|
|
position: { x: 0, y: 0 },
|
|
data: { active: hasActivity },
|
|
})
|
|
|
|
const visibleSessions = selectedSession
|
|
? sessions.filter((s) => s.key === selectedSession)
|
|
: sessions
|
|
|
|
for (const session of visibleSessions) {
|
|
nodes.push({
|
|
id: `session-${session.key}`,
|
|
type: 'session',
|
|
position: { x: 0, y: 0 },
|
|
data: session as unknown as Record<string, unknown>,
|
|
})
|
|
}
|
|
|
|
for (const action of visibleActions) {
|
|
nodes.push({
|
|
id: `action-${action.id}`,
|
|
type: 'action',
|
|
position: { x: 0, y: 0 },
|
|
data: action as unknown as Record<string, unknown>,
|
|
})
|
|
}
|
|
|
|
return nodes
|
|
}, [sessions, visibleActions, selectedSession])
|
|
|
|
// Build edges
|
|
const rawEdges = useMemo(() => {
|
|
const edges: Edge[] = []
|
|
|
|
const visibleSessions = selectedSession
|
|
? 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 },
|
|
})
|
|
}
|
|
|
|
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
|
|
|
|
const sessionActions = new Map<string, MonitorAction[]>()
|
|
for (const action of visibleActions) {
|
|
const key = action.sessionKey
|
|
if (!key || key === 'lifecycle') continue
|
|
const list = sessionActions.get(key) ?? []
|
|
list.push(action)
|
|
sessionActions.set(key, list)
|
|
}
|
|
|
|
const getEdgeStyle = (action: MonitorAction) => {
|
|
switch (action.type) {
|
|
case 'start':
|
|
return {
|
|
animated: true,
|
|
style: { stroke: '#98ffc8', strokeDasharray: '5 5' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
|
|
}
|
|
case 'streaming':
|
|
return {
|
|
animated: true,
|
|
style: { stroke: '#00ffd5' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
|
|
}
|
|
case 'complete':
|
|
return {
|
|
animated: false,
|
|
style: { stroke: '#98ffc8' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
|
|
}
|
|
case 'error':
|
|
return {
|
|
animated: false,
|
|
style: { stroke: '#ef4444' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
|
}
|
|
case 'aborted':
|
|
return {
|
|
animated: false,
|
|
style: { stroke: '#ffb399' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#ffb399' },
|
|
}
|
|
default:
|
|
return {
|
|
animated: false,
|
|
style: { stroke: '#52526e' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' },
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [sessionKey, actions] of sessionActions) {
|
|
const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
|
const sessionId = `session-${sessionKey}`
|
|
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
const action = sorted[i]!
|
|
const edgeStyle = getEdgeStyle(action)
|
|
|
|
if (i === 0) {
|
|
if (sessionNodeIds.has(sessionId)) {
|
|
edges.push({
|
|
id: `e-session-${action.id}`,
|
|
source: sessionId,
|
|
target: `action-${action.id}`,
|
|
...edgeStyle,
|
|
})
|
|
}
|
|
} else {
|
|
const prev = sorted[i - 1]!
|
|
edges.push({
|
|
id: `e-${prev.id}-${action.id}`,
|
|
source: `action-${prev.id}`,
|
|
target: `action-${action.id}`,
|
|
...edgeStyle,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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: [],
|
|
}
|
|
}
|
|
return layoutGraph(rawNodes, rawEdges, {
|
|
direction: 'TB',
|
|
nodeWidth: 200,
|
|
nodeHeight: 80,
|
|
rankSep: 60,
|
|
nodeSep: 30,
|
|
})
|
|
}, [rawNodes, rawEdges])
|
|
|
|
// Initial nodes with chaser (click handler added later)
|
|
const initialNodes = useMemo(() => {
|
|
const crab = crabRef.current
|
|
const chaserNode: Node = {
|
|
id: CHASER_CRAB_ID,
|
|
type: 'chaserCrab',
|
|
position: crab.position,
|
|
data: {
|
|
state: crab.state,
|
|
facingLeft: crab.facingLeft,
|
|
onClick: () => {}, // Placeholder, updated after setNodes is available
|
|
},
|
|
draggable: false,
|
|
selectable: false,
|
|
zIndex: 1000,
|
|
}
|
|
return [...layoutedNodes, chaserNode]
|
|
}, [])
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
|
|
|
// Handle crab click - jump animation (defined after setNodes)
|
|
const handleCrabClick = useCallback(() => {
|
|
const crab = crabRef.current
|
|
if (crab.state === 'attacking' || crab.state === 'jumping') return
|
|
|
|
// Remember what state to return to
|
|
const previousState = crab.state
|
|
const hadTarget = crab.target !== null
|
|
|
|
crab.state = 'jumping'
|
|
|
|
// Immediately update the node to show jump animation
|
|
setNodes((nds) =>
|
|
nds.map((n) =>
|
|
n.id === CHASER_CRAB_ID
|
|
? {
|
|
...n,
|
|
data: {
|
|
...n.data,
|
|
state: 'jumping',
|
|
},
|
|
}
|
|
: n
|
|
)
|
|
)
|
|
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
timeoutRef.current = setTimeout(() => {
|
|
// Resume previous behavior
|
|
if (hadTarget && crab.target) {
|
|
// Still has target, go back to chasing/wandering
|
|
crab.state = previousState === 'wandering' ? 'wandering' : 'chasing'
|
|
} else {
|
|
crab.state = 'idle'
|
|
crab.idleUntil = Date.now() + IDLE_PAUSE_MIN
|
|
}
|
|
|
|
setNodes((nds) =>
|
|
nds.map((n) =>
|
|
n.id === CHASER_CRAB_ID
|
|
? {
|
|
...n,
|
|
data: {
|
|
...n.data,
|
|
state: crab.state,
|
|
},
|
|
}
|
|
: n
|
|
)
|
|
)
|
|
}, 400)
|
|
}, [setNodes])
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges)
|
|
|
|
// Detect new nodes and moved nodes
|
|
useEffect(() => {
|
|
const currentIds = new Set(layoutedNodes.map((n) => n.id))
|
|
const prevIds = prevNodeIdsRef.current
|
|
const prevPositions = nodePositionsRef.current
|
|
const crab = crabRef.current
|
|
|
|
// Check for new nodes
|
|
for (const node of layoutedNodes) {
|
|
if (!node.id.includes('crab')) {
|
|
const nodeCenter = {
|
|
x: node.position.x + 100,
|
|
y: node.position.y + 40,
|
|
}
|
|
|
|
// New node - chase it
|
|
if (!prevIds.has(node.id)) {
|
|
crab.target = { ...nodeCenter, nodeId: node.id }
|
|
crab.state = 'chasing'
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
break
|
|
}
|
|
|
|
// Existing node moved - if we were tracking it or idle, chase it
|
|
const prevPos = prevPositions.get(node.id)
|
|
if (prevPos) {
|
|
const moved = Math.abs(prevPos.x - node.position.x) > 5 ||
|
|
Math.abs(prevPos.y - node.position.y) > 5
|
|
if (moved && (crab.state === 'idle' || crab.target?.nodeId === node.id)) {
|
|
crab.target = { ...nodeCenter, nodeId: node.id }
|
|
crab.state = 'chasing'
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
}
|
|
}
|
|
|
|
prevPositions.set(node.id, { x: node.position.x, y: node.position.y })
|
|
}
|
|
}
|
|
|
|
prevNodeIdsRef.current = currentIds
|
|
}, [layoutedNodes])
|
|
|
|
// Main animation loop - step-based crab movement at 10fps timing
|
|
useEffect(() => {
|
|
const animate = (timestamp: number) => {
|
|
const crab = crabRef.current
|
|
let needsUpdate = false
|
|
|
|
// State machine
|
|
switch (crab.state) {
|
|
case 'idle': {
|
|
// Wait for idle pause to end
|
|
if (timestamp < crab.idleUntil) break
|
|
|
|
// Randomly decide to wander
|
|
if (Math.random() < WANDER_CHANCE) {
|
|
const angle = Math.random() * Math.PI * 2
|
|
const distance = Math.random() * WANDER_RADIUS + 20
|
|
crab.target = {
|
|
x: crab.position.x + Math.cos(angle) * distance,
|
|
y: crab.position.y + Math.sin(angle) * distance,
|
|
}
|
|
crab.state = 'wandering'
|
|
crab.lastStepTime = timestamp
|
|
needsUpdate = true
|
|
} else {
|
|
// Set another idle pause
|
|
crab.idleUntil = timestamp + IDLE_PAUSE_MIN + Math.random() * (IDLE_PAUSE_MAX - IDLE_PAUSE_MIN)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'wandering': {
|
|
if (!crab.target) {
|
|
crab.state = 'idle'
|
|
crab.idleUntil = timestamp + IDLE_PAUSE_MIN + Math.random() * (IDLE_PAUSE_MAX - IDLE_PAUSE_MIN)
|
|
needsUpdate = true
|
|
break
|
|
}
|
|
|
|
// Only move on step intervals (10fps = 100ms)
|
|
if (timestamp - crab.lastStepTime < STEP_INTERVAL) break
|
|
crab.lastStepTime = timestamp
|
|
|
|
const dx = crab.target.x - crab.position.x
|
|
const dy = crab.target.y - crab.position.y
|
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
if (distance < WANDER_STEP_SIZE) {
|
|
crab.state = 'idle'
|
|
crab.target = null
|
|
crab.idleUntil = timestamp + IDLE_PAUSE_MIN + Math.random() * (IDLE_PAUSE_MAX - IDLE_PAUSE_MIN)
|
|
needsUpdate = true
|
|
break
|
|
}
|
|
|
|
// Crab scuttles - mostly sideways with forward bias
|
|
const dirX = dx / distance
|
|
const dirY = dy / distance
|
|
// Add sideways drift perpendicular to movement direction
|
|
const perpX = -dirY * SIDEWAYS_DRIFT * (Math.random() > 0.5 ? 1 : -1)
|
|
const perpY = dirX * SIDEWAYS_DRIFT * (Math.random() > 0.5 ? 1 : -1)
|
|
|
|
const stepX = (dirX + perpX) * WANDER_STEP_SIZE
|
|
const stepY = (dirY + perpY) * WANDER_STEP_SIZE
|
|
|
|
if (Math.abs(stepX) > 0.5) {
|
|
crab.facingLeft = stepX < 0
|
|
}
|
|
|
|
crab.position.x += stepX
|
|
crab.position.y += stepY
|
|
needsUpdate = true
|
|
break
|
|
}
|
|
|
|
case 'chasing': {
|
|
if (!crab.target) {
|
|
crab.state = 'idle'
|
|
crab.idleUntil = timestamp + IDLE_PAUSE_MIN
|
|
needsUpdate = true
|
|
break
|
|
}
|
|
|
|
// Only move on step intervals
|
|
if (timestamp - crab.lastStepTime < STEP_INTERVAL) break
|
|
crab.lastStepTime = timestamp
|
|
|
|
// If tracking a node, update target position
|
|
if (crab.target.nodeId) {
|
|
const trackedNode = layoutedNodes.find((n) => n.id === crab.target!.nodeId)
|
|
if (trackedNode) {
|
|
crab.target.x = trackedNode.position.x + 100
|
|
crab.target.y = trackedNode.position.y + 40
|
|
}
|
|
}
|
|
|
|
const dx = crab.target.x - crab.position.x
|
|
const dy = crab.target.y - crab.position.y
|
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
if (distance < ATTACK_DISTANCE) {
|
|
// Reached target - attack or idle
|
|
if (Math.random() < ATTACK_CHANCE) {
|
|
crab.state = 'attacking'
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
timeoutRef.current = setTimeout(() => {
|
|
crab.state = 'idle'
|
|
crab.target = null
|
|
crab.idleUntil = Date.now() + IDLE_PAUSE_MIN
|
|
}, 400)
|
|
} else {
|
|
crab.state = 'idle'
|
|
crab.target = null
|
|
crab.idleUntil = timestamp + IDLE_PAUSE_MIN
|
|
}
|
|
needsUpdate = true
|
|
break
|
|
}
|
|
|
|
// Crab scuttles toward target with sideways drift
|
|
const dirX = dx / distance
|
|
const dirY = dy / distance
|
|
const perpX = -dirY * SIDEWAYS_DRIFT * (Math.random() > 0.5 ? 1 : -1)
|
|
const perpY = dirX * SIDEWAYS_DRIFT * (Math.random() > 0.5 ? 1 : -1)
|
|
|
|
const stepX = (dirX + perpX) * STEP_SIZE
|
|
const stepY = (dirY + perpY) * STEP_SIZE
|
|
|
|
if (Math.abs(stepX) > 0.5) {
|
|
crab.facingLeft = stepX < 0
|
|
}
|
|
|
|
crab.position.x += stepX
|
|
crab.position.y += stepY
|
|
needsUpdate = true
|
|
break
|
|
}
|
|
|
|
case 'attacking':
|
|
case 'jumping':
|
|
// Time-limited states handled by timeouts
|
|
break
|
|
}
|
|
|
|
// Update the node if needed
|
|
if (needsUpdate) {
|
|
setNodes((nds) =>
|
|
nds.map((n) =>
|
|
n.id === CHASER_CRAB_ID
|
|
? {
|
|
...n,
|
|
position: { ...crab.position },
|
|
data: {
|
|
state: crab.state,
|
|
facingLeft: crab.facingLeft,
|
|
onClick: handleCrabClick,
|
|
},
|
|
}
|
|
: n
|
|
)
|
|
)
|
|
}
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate)
|
|
|
|
return () => {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current)
|
|
}
|
|
}
|
|
}, [layoutedNodes, setNodes, handleCrabClick])
|
|
|
|
// Update layout nodes when they change (preserve chaser)
|
|
useEffect(() => {
|
|
setNodes((nds) => {
|
|
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
|
|
if (chaserNode) {
|
|
return [...layoutedNodes, chaserNode]
|
|
}
|
|
const crab = crabRef.current
|
|
return [
|
|
...layoutedNodes,
|
|
{
|
|
id: CHASER_CRAB_ID,
|
|
type: 'chaserCrab',
|
|
position: crab.position,
|
|
data: {
|
|
state: crab.state,
|
|
facingLeft: crab.facingLeft,
|
|
onClick: handleCrabClick,
|
|
},
|
|
draggable: false,
|
|
selectable: false,
|
|
zIndex: 1000,
|
|
},
|
|
]
|
|
})
|
|
setEdges(layoutedEdges)
|
|
}, [layoutedNodes, layoutedEdges, setNodes, setEdges, handleCrabClick])
|
|
|
|
// Cleanup
|
|
useEffect(() => {
|
|
return () => {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current)
|
|
}
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const onNodeClick = useCallback(
|
|
(_: React.MouseEvent, node: Node) => {
|
|
if (node.type === 'session') {
|
|
const sessionKey = (node.data as unknown as MonitorSession).key
|
|
onSessionSelect(selectedSession === sessionKey ? null : sessionKey)
|
|
}
|
|
},
|
|
[onSessionSelect, selectedSession]
|
|
)
|
|
|
|
return (
|
|
<div className="w-full h-full bg-shell-950 texture-grid relative">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={onNodeClick}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
minZoom={0.1}
|
|
maxZoom={2}
|
|
proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background color="#252535" gap={24} size={1} />
|
|
<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!"
|
|
/>
|
|
<MiniMap
|
|
nodeColor={(node) => {
|
|
if (node.type === 'crab') return '#ef4444'
|
|
if (node.type === 'chaserCrab') return '#ef4444'
|
|
if (node.type === 'session') return '#98ffc8'
|
|
return '#52526e'
|
|
}}
|
|
maskColor="rgba(10, 10, 15, 0.8)"
|
|
className="bg-shell-900! border-shell-700!"
|
|
style={{ backgroundColor: '#0a0a0f' }}
|
|
/>
|
|
</ReactFlow>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Wrapper that provides ReactFlowProvider
|
|
export function ActionGraph(props: ActionGraphProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<ActionGraphInner {...props} />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|