Compare commits

..
Author SHA1 Message Date
luccast 45e82efaf3 fix(monitor): handle clipboard copy errors in ExecNode and SessionNode
- Updated clipboard copy functionality in ExecNode and SessionNode to handle potential errors by adding a catch block to the writeText method.
- Ensured that the user experience remains smooth by maintaining the existing feedback mechanism for copy actions.
2026-01-28 12:14:00 -05:00
luccast 5fb52e6e10 refactor(monitor, graph): introduce nodeData utility for type casting
- Added a utility function `nodeData` to cast domain data to ReactFlow's Node data type for improved type safety.
- Updated the ActionGraph and graph layout components to utilize the new `nodeData` function for data handling.
2026-01-28 12:11:25 -05:00
luccast 8dac1e0bef fix(clawdbot): optimize output chunk truncation logic
The new version iterates forward, accumulating dropped chars from the front until the remaining tail fits within budget. This keeps the maximum number of recent chunks possible.

- Refactored the logic for truncating output chunks to improve performance and readability.
- Changed variable declarations for clarity and adjusted the loop to determine the starting index for slicing the capped array.
2026-01-28 12:06:39 -05:00
luccast ab395e5dfb chore: update package dependencies and remove unused packages
- Removed "@types/dagre" and "dagre" from package.json and package-lock.json
- Added "peer": true to several dependencies in package-lock.json for better compatibility
2026-01-28 12:04:31 -05:00
luccast 1b6920e94b fix: Removed the Ctrl+K shortcut. The "clear completed" button in the UI still works — no need for a global keyboard shortcut that conflicts with browser/OS defaults. 2026-01-28 12:03:54 -05:00
luccast 186c59fa82 fix(monitor): add periodic update for session node component
- Introduced useEffect to trigger a state update every 30 seconds
- Simplified relative time calculation for better readability
2026-01-28 12:02:40 -05:00
luccast 1187b35268 chore: remove non-feature files from contributor PR
Remove planning docs, security audit, vitest config, and revert
package.json/lockfile to original state.
2026-01-28 11:50:37 -05:00
Grace (Clawdbot) 068af76bb7 fix(ui): improve graph spacing and alignment
- Increase COLUMN_GAP from 300 to 400 for wider horizontal separation
- Increase ROW_GAP from 40 to 80 for more vertical breathing room
- Enlarge NODE_DIMENSIONS for better layout calculations
- Add MIN_SESSION_GAP (120px) for collision avoidance
- Implement adjustSpawnY to shift overlapping sessions down
- Sort sessions by column before positioning (parents first)
- Ensure all nodes in same depth share same X coordinate
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) eca161223f feat(ui): horizontal spawn layout for action graph
- Redesigned graph layout algorithm for horizontal spawn positioning
- Sessions arranged in columns (X = spawn depth hierarchy)
- Events within a session flow DOWN vertically (Y = time progression)
- Child sessions appear to the RIGHT at the Y-level where spawned
- Added left/right handles on SessionNode for horizontal spawn edges
- Smoothstep edges connect parent → child sessions horizontally
- Collision avoidance for multiple sessions at same depth
- Orphan nodes (without session) get positioned in separate area
- Compact action nodes (180x80) vs larger session/exec nodes
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 3949991b6c feat(monitor): add copy session key button to SessionNode header
- Copy icon positioned left of status indicator
- Click copies full session key to clipboard
- Animated feedback: copy → checkmark for 1.5s
- stopPropagation prevents node selection interference
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 648539033c feat(monitor): add copy PID button to ExecNode header
- Copy icon positioned left of status indicator
- Click copies PID to clipboard
- Animated feedback: copy → checkmark for 1.5s
- stopPropagation prevents expand/collapse on click
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) d2e4f3be43 feat(monitor): add clear completed execs button
- Add clearCompletedExecs() to remove completed/failed execs
- Add getCompletedExecCount() for UI badge count
- Add clearInactiveSessions() for optional inactive session cleanup
- Add "Clear Completed" button in monitor header with badge count
- Button only shows when there are clearable items
- Add keyboard shortcut: Ctrl+K / Cmd+K to clear
- Add vitest test infrastructure with 8 tests covering:
  - Clearing completed execs
  - Clearing failed execs
  - Preserving running execs
  - Counting clearable items
  - Clearing inactive sessions by threshold
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 89177787bf feat(monitor): link subagents to parent sessions via spawnedBy, add timestamps to session nodes 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) e8142d05cf fix(parser): remove incorrect sessionKey assignment 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 9c00470e44 feat(exec-events): bead-10 - final validation and hydrate exec-only data 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) c9d442b5f9 feat(exec-events): bead-9 - render exec nodes under sessions 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) bc4e6a0709 feat(exec-events): bead-8 - add exec node component 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 1f4209d8b7 feat(exec-events): bead-7 - wire exec events into monitor route 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) f604f056e7 feat(exec-events): bead-6 - emit exec events in subscription 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 7268338264 feat(exec-events): bead-5 - hydrate exec events after actions 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 9b892e25a2 feat(exec-events): bead-4 - persist exec events 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) dabd4bcadc feat(exec-events): bead-3 - add exec collection and aggregation 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 87b99713a0 feat(exec-events): bead-2 - parse exec started output completed 2026-01-28 11:42:13 -05:00
Grace (Clawdbot) b705bb1ba8 feat(exec-events): bead-1 - add exec protocol and monitor types 2026-01-28 11:42:13 -05:00
16 changed files with 392 additions and 1927 deletions
-1
View File
@@ -3,4 +3,3 @@
# Clawdbot gateway auth token
CLAWDBOT_API_TOKEN=
+14 -5
View File
@@ -1,4 +1,6 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
@@ -7,15 +9,22 @@ RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# 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.
COPY --from=builder /app/.output ./.output
ENV NODE_ENV=development
COPY package*.json ./
RUN npm ci
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
+1 -5
View File
@@ -28,10 +28,8 @@ docker run -d \
ghcr.io/luccast/crabwalk:latest
```
> Note: When running Crabwalk in Docker, the Moltbot gateway typically runs on the _host_.
> Note: When running Crabwalk in Docker, the Clawdbot 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:
@@ -40,8 +38,6 @@ 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
+108 -1221
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -1,16 +1,15 @@
{
"name": "crabwalk",
"version": "1.0.5",
"version": "1.0.4",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000 --host",
"build": "vite build",
"start": "node .output/server/index.mjs"
"start": "node dist/server/server.js"
},
"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",
@@ -27,7 +26,8 @@
"superjson": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4",
"ws": "^8.19.0",
"zod": "^3.24.0"
"zod": "^3.24.0",
"@tanstack/history": "^1.132.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@@ -36,12 +36,8 @@
"@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"
}
}
+22 -183
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
ReactFlow,
Background,
@@ -6,16 +6,12 @@ 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'
@@ -35,7 +31,6 @@ interface ActionGraphProps {
execs: MonitorExecProcess[]
selectedSession: string | null
onSessionSelect: (key: string | null) => void
isHydrating?: boolean
}
/** Cast domain data to ReactFlow's Node data type */
@@ -83,7 +78,6 @@ function ActionGraphInner({
execs,
selectedSession,
onSessionSelect,
isHydrating = false,
}: ActionGraphProps) {
// Crab AI state
const crabRef = useRef<CrabAI>({
@@ -97,33 +91,9 @@ 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)
@@ -364,51 +334,22 @@ function ActionGraphInner({
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: [],
}
// 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: 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 ?? []
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(() => {
@@ -429,25 +370,7 @@ function ActionGraphInner({
return [...layoutedNodes, chaserNode]
}, [])
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]
)
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
// Handle crab click - jump animation (defined after setNodes)
const handleCrabClick = useCallback(() => {
@@ -510,9 +433,6 @@ 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')) {
@@ -526,7 +446,7 @@ function ActionGraphInner({
crab.target = { ...nodeCenter, nodeId: node.id }
crab.state = 'chasing'
if (timeoutRef.current) clearTimeout(timeoutRef.current)
latestNewNode = nodeCenter
break
}
// Existing node moved - if we were tracking it or idle, chase it
@@ -545,17 +465,8 @@ 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, followMode, setCenter])
}, [layoutedNodes])
// Main animation loop - step-based crab movement at 10fps timing
useEffect(() => {
@@ -731,22 +642,16 @@ function ActionGraphInner({
}
}, [layoutedNodes, setNodes, handleCrabClick])
// Update layout nodes when they change (preserve chaser + pinned positions)
// Update layout nodes when they change (preserve chaser)
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 [...mergedNodes, chaserNode]
return [...layoutedNodes, chaserNode]
}
const crab = crabRef.current
return [
...mergedNodes,
...layoutedNodes,
{
id: CHASER_CRAB_ID,
type: 'chaserCrab',
@@ -765,18 +670,6 @@ 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 () => {
@@ -818,60 +711,6 @@ 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'
@@ -887,7 +726,7 @@ function ActionGraphInner({
}}
maskColor="rgba(10, 10, 15, 0.8)"
className="bg-shell-900! border-shell-700!"
style={{ backgroundColor: '#0a0a0f', width: 100, height: 75 }}
style={{ backgroundColor: '#0a0a0f' }}
/>
</ReactFlow>
</div>
+7 -5
View File
@@ -10,6 +10,7 @@ import {
MessageSquare,
MessageCircle,
Bot,
Play,
} from 'lucide-react'
import type { MonitorAction } from '~/integrations/clawdbot'
@@ -38,11 +39,12 @@ const stateConfig: Record<
}
> = {
start: {
icon: Loader2,
borderColor: 'border-neon-cyan',
bgColor: 'bg-neon-cyan/10',
iconColor: 'text-neon-cyan',
animate: true,
icon: Play,
borderColor: 'border-neon-mint',
bgColor: 'bg-neon-mint/10',
iconColor: 'text-neon-mint',
animate: false,
label: 'Run Started',
},
streaming: {
icon: Loader2,
+1 -1
View File
@@ -230,7 +230,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
</span>
<span>{formatTime(chunk.timestamp)}</span>
</div>
<pre className="font-console text-[11px] whitespace-pre-wrap wrap-break-workds">
<pre className="font-console text-[11px] whitespace-pre-wrap break-words">
{chunk.text}
</pre>
</div>
+55 -207
View File
@@ -1,13 +1,9 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Users, ChevronLeft, ChevronRight, ChevronDown, Github } from "lucide-react";
import { Users, ChevronLeft, ChevronRight, 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,
@@ -45,54 +41,6 @@ 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,
@@ -102,12 +50,10 @@ export function SessionList({
}: SessionListProps) {
const [filter, setFilter] = useState("");
const [platformFilter, setPlatformFilter] = useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const parentSessions = sessions.filter((s) => !isSubagent(s));
const platforms = [...new Set(parentSessions.map((s) => s.platform))];
const platforms = [...new Set(sessions.map((s) => s.platform))];
const filteredParents = parentSessions.filter((session) => {
const filteredSessions = sessions.filter((session) => {
const matchesText =
!filter ||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
@@ -118,45 +64,12 @@ export function SessionList({
});
// Sort: active first, then by lastActivityAt
const sortedParents = [...filteredParents].sort((a, b) => {
const sortedSessions = [...filteredSessions].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"
@@ -206,7 +119,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-[11px] font-display uppercase tracking-wide rounded border transition-all ${
className={`px-2.5 py-1 text-[10px] 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"
@@ -218,7 +131,7 @@ export function SessionList({
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
className={`px-2.5 py-1 text-[10px] 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"
@@ -236,129 +149,64 @@ export function SessionList({
{/* Session list */}
<div className="relative flex-1 overflow-y-auto">
<AnimatePresence mode="popLayout">
{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">
{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">
<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="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>
<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}
<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
</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}
/>
)}
</div>
</>
)}
</motion.button>
))}
</AnimatePresence>
{sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
{sortedSessions.length === 0 && !collapsed && (
<div className="p-6 text-center">
<div className="font-console text-xs text-shell-500">
<span className="text-crab-600">&gt;</span> no sessions found
+27 -28
View File
@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from 'framer-motion'
import { Settings, X, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square, CloudDownload } from 'lucide-react'
import { version } from '../../../package.json'
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square } from 'lucide-react'
interface SettingsPanelProps {
connected: boolean
@@ -138,6 +137,31 @@ 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">&gt;</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">
@@ -219,31 +243,6 @@ 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">&gt;</span> fetch 24h of sessions from gateway on refresh
</p>
</div>
</div>
{/* Log collection */}
@@ -323,7 +322,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 v{version}
crabwalk v1.0.1
</span>
</div>
</div>
+94 -87
View File
@@ -12,13 +12,12 @@ import {
// 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
// Track recent activity on parent (non-subagent) sessions for spawn inference
// Maps sessionKey → lastActivityTimestamp
const parentSessionActivity = new Map<string, number>()
// Time window for spawn inference
const SPAWN_INFERENCE_WINDOW_MS = 10000
// Time window for spawn inference - parent must have been active within this window
const SPAWN_INFERENCE_WINDOW_MS = 5000
function isSubagentSession(key: string): boolean {
return key.includes('subagent')
@@ -28,60 +27,33 @@ 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
// Infer which parent session spawned this subagent based on recent activity
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
const now = timestamp ?? Date.now()
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
}
for (const [parentKey, activityTime] of parentSessionActivity) {
// Must be within inference window
if (now - activityTime > SPAWN_INFERENCE_WINDOW_MS) continue
// Pick most recently active parent
if (activityTime > bestTime) {
bestTime = activityTime
bestParent = parentKey
}
}
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
}
// Track activity on a parent session
function trackParentActivity(sessionKey: string, timestamp?: number) {
if (!isParentSession(sessionKey)) return
parentSessionActivity.set(sessionKey, timestamp ?? Date.now())
}
export const sessionsCollection = createCollection(
localOnlyCollectionOptions<MonitorSession>({
id: 'clawdbot-sessions',
@@ -192,6 +164,11 @@ function createPlaceholderExec(event: MonitorExecEvent, sessionKey?: string): Mo
// Helper to update or insert session
export function upsertSession(session: MonitorSession) {
// Track activity on parent sessions
if (isParentSession(session.key)) {
trackParentActivity(session.key, session.lastActivityAt)
}
const existing = sessionsCollection.state.get(session.key)
if (existing) {
@@ -217,9 +194,10 @@ export function upsertSession(session: MonitorSession) {
}
// Helper to add or update action
// Unified node lifecycle per runId:
// - start/streaming/complete/error/aborted all update the same node
// - Node type reflects current state in the lifecycle
// 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
// - tool_call/tool_result: separate nodes
export function addAction(action: MonitorAction) {
// Learn runId → sessionKey mapping from actions with real session keys
@@ -230,9 +208,9 @@ export function addAction(action: MonitorAction) {
backfillExecSessionKey(action.runId, action.sessionKey)
}
// Track parent session actions for spawn inference
// Track activity on parent sessions for spawn inference
if (isParentSession(action.sessionKey)) {
trackParentAction(action.sessionKey, action.timestamp)
trackParentActivity(action.sessionKey, action.timestamp)
}
}
@@ -242,49 +220,73 @@ export function addAction(action: MonitorAction) {
sessionKey = runSessionMap.get(action.runId) || sessionKey
}
// 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)
// 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
}
// 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) {
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
}
// Update content if present
// 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
}
// Copy metadata from complete/error events
// 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) => {
draft.type = action.type
draft.seq = action.seq
draft.timestamp = action.timestamp
if (sessionKey && sessionKey !== 'lifecycle') {
draft.sessionKey = sessionKey
}
// Copy metadata from complete event
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
}
})
} else {
// Create new action node
actionsCollection.insert({
...action,
id: actionNodeId,
sessionKey,
})
return
}
// No streaming action found, create as-is with complete state
actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` })
return
}
@@ -393,6 +395,12 @@ export function updateSessionStatus(
status: MonitorSession['status']
) {
const now = Date.now()
// Track activity on parent sessions
if (isParentSession(key)) {
trackParentActivity(key, now)
}
const session = sessionsCollection.state.get(key)
if (session) {
sessionsCollection.update(key, (draft) => {
@@ -429,7 +437,7 @@ export function updateSession(key: string, update: Partial<MonitorSession>) {
// Clear all data
export function clearCollections() {
runSessionMap.clear()
parentActionHistory.clear()
parentSessionActivity.clear()
for (const session of sessionsCollection.state.values()) {
sessionsCollection.delete(session.key)
}
@@ -495,24 +503,23 @@ export function hydrateFromServer(
// First clear existing data
clearCollections()
// Sort actions by timestamp for replay
// Replay actions first to build parent activity history
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
// First pass: build parent action history for spawn inference
for (const action of sortedActions) {
// Track parent activity without inserting actions yet
if (action.sessionKey && isParentSession(action.sessionKey)) {
trackParentAction(action.sessionKey, action.timestamp)
trackParentActivity(action.sessionKey, action.timestamp)
}
}
// Also track parent sessions by their lastActivityAt
for (const session of sessions) {
if (isParentSession(session.key)) {
trackParentAction(session.key, session.lastActivityAt)
trackParentActivity(session.key, session.lastActivityAt)
}
}
// Insert all sessions - subagents will get inferred spawnedBy from Task tool calls
// Now insert all sessions - subagents will get inferred spawnedBy
for (const session of sessions) {
if (isSubagentSession(session.key)) {
const spawnedBy = session.spawnedBy || inferSpawnedBy(session.key, session.lastActivityAt)
+9 -30
View File
@@ -113,11 +113,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
if (event.stream === 'lifecycle') {
if (data.phase === 'start') {
type = 'start'
// No placeholder content - will show "Run Started" label from UI
content = 'Run started'
startedAt = typeof data.startedAt === 'number' ? data.startedAt : event.ts
} else if (data.phase === 'end') {
type = 'complete'
// No placeholder content - preserve streamed content from assistant events
content = 'Run completed'
endedAt = typeof data.endedAt === 'number' ? data.endedAt : event.ts
}
} else if (data.type === 'tool_use') {
@@ -128,8 +128,7 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
} else if (data.type === 'tool_result') {
type = 'tool_result'
content = String(data.content || '')
} else if (data.type === 'text' || typeof data.text === 'string') {
// Handle both { type: 'text', text: '...' } and assistant stream { text: '...', delta: '...' }
} else if (data.type === 'text') {
type = 'streaming'
content = String(data.text || '')
}
@@ -178,7 +177,12 @@ export function parseEventFrame(
if (frame.event === 'agent' && frame.payload) {
const agentEvent = frame.payload as AgentEvent
// Process lifecycle events (start/end markers)
// Skip assistant stream - it duplicates chat events
if (agentEvent.stream === 'assistant') {
return null
}
// Only process lifecycle events (start/end markers)
if (agentEvent.stream === 'lifecycle') {
return {
action: agentEventToAction(agentEvent),
@@ -190,31 +194,6 @@ 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
}
+35 -121
View File
@@ -31,18 +31,11 @@ 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
@@ -53,19 +46,16 @@ interface SessionColumn {
}
/**
* 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)
* Horizontal spawn layout algorithm:
* - Sessions arranged in columns (X = spawn depth)
* - Events within a session flow DOWN (Y = time progression)
* - Child sessions appear to the RIGHT at the Y-level where they were spawned
*/
export function layoutGraph(
nodes: Node[],
edges: Edge[],
options: LayoutOptions = {}
_options: LayoutOptions = {}
): { nodes: Node[]; edges: Edge[] } {
const direction = options.direction ?? 'LR'
const isHorizontal = direction === 'LR' || direction === 'RL'
// Build session hierarchy and columns
const sessions = nodes
.filter((n) => n.type === 'session')
@@ -85,42 +75,8 @@ export function layoutGraph(
const sessionColumns = new Map<string, SessionColumn>()
const columnOccupancy = new Map<number, number>() // columnIndex -> maxY used
// 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 => {
// First pass: determine column for each session based on spawn hierarchy
const getSessionColumn = (sessionKey: string, visited = new Set<string>()): number => {
if (visited.has(sessionKey)) return 0
visited.add(sessionKey)
@@ -128,20 +84,18 @@ export function layoutGraph(
if (!session) return 0
if (session.spawnedBy) {
return getSessionDepth(session.spawnedBy, visited) + 1
return getSessionColumn(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)
const columnIndex = getSessionColumn(session.key)
sessionColumns.set(session.key, {
sessionKey: session.key,
columnIndex: depth,
rootIndex,
spawnY: depth === 0 ? ROOT_START_Y : 0, // Root sessions start below crab
columnIndex,
spawnY: 0,
items: [],
})
}
@@ -219,7 +173,6 @@ export function layoutGraph(
// Calculate spawn Y positions for child sessions
// When a session is spawned, find the Y position of the parent at that time
// Cache these values so they don't jitter as parent actions accumulate
for (const session of sessions) {
if (!session.spawnedBy) continue
@@ -227,13 +180,6 @@ export function layoutGraph(
const childCol = sessionColumns.get(session.key)
if (!parentCol || !childCol) continue
// Use cached spawn Y if available (prevents jitter from recalculation)
const cachedSpawnY = spawnYCache.get(session.key)
if (cachedSpawnY !== undefined) {
childCol.spawnY = cachedSpawnY
continue
}
// Find the approximate position in parent where spawn happened
// Use the child's creation time (approximated by first action time or session activity)
const childActions = actionsBySession.get(session.key) ?? []
@@ -251,10 +197,8 @@ export function layoutGraph(
}
}
// 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
// Calculate Y based on parent's item count
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
}
// Position all nodes
@@ -270,81 +214,59 @@ export function layoutGraph(
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 }>>()
// Track column usage for collision avoidance: columnIndex -> list of {startY, endY} ranges
const columnRanges = new Map<number, 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
}
// Get X position for a column (all nodes in same column share same X)
const getColumnX = (columnIndex: number): number => {
return columnIndex * COLUMN_GAP
}
// Adjust spawn Y to avoid collisions with existing sessions in same column
const adjustSpawnY = (rootIndex: number, columnIndex: number, desiredY: number, itemCount: number): number => {
const key = getColumnKey(rootIndex, columnIndex)
const ranges = columnRanges.get(key) ?? []
const adjustSpawnY = (columnIndex: number, desiredY: number, itemCount: number): number => {
const ranges = columnRanges.get(columnIndex) ?? []
const estimatedHeight = itemCount * (NODE_DIMENSIONS.action.height + ROW_GAP) + MIN_SESSION_GAP
let adjustedY = desiredY
// Check for overlaps and shift down if needed
for (const range of ranges) {
// If our desired position overlaps with an existing range
if (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) {
// Shift below this range with minimum gap
adjustedY = range.endY + MIN_SESSION_GAP
}
}
// Record our range
ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight })
columnRanges.set(key, ranges)
columnRanges.set(columnIndex, ranges)
return adjustedY
}
// Sort sessions by column index (process column 0 first, then 1, etc.)
// This ensures parent sessions are positioned before children
const sortedSessionKeys = Array.from(sessionColumns.keys()).sort((a, b) => {
const colA = sessionColumns.get(a)!
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
}
const colA = sessionColumns.get(a)!.columnIndex
const colB = sessionColumns.get(b)!.columnIndex
if (colA !== colB) return colA - colB
// Within same column, sort by spawn Y (earlier spawns first)
return colA.spawnY - colB.spawnY
return sessionColumns.get(a)!.spawnY - sessionColumns.get(b)!.spawnY
})
// Position each session's column
for (const sessionKey of sortedSessionKeys) {
const col = sessionColumns.get(sessionKey)!
const columnX = getColumnX(col.rootIndex, col.columnIndex)
const columnX = getColumnX(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)
const adjustedY = adjustSpawnY(col.columnIndex, col.spawnY, col.items.length)
let currentY = adjustedY
for (const item of col.items) {
const dims = NODE_DIMENSIONS[item.type]
positionedNodes.push({
id: item.nodeId,
type: item.type,
@@ -376,14 +298,6 @@ export function layoutGraph(
}
}
// Clean up spawn Y cache for sessions that no longer exist
const currentSessionKeys = new Set(sessions.map(s => s.key))
for (const key of spawnYCache.keys()) {
if (!currentSessionKeys.has(key)) {
spawnYCache.delete(key)
}
}
return { nodes: positionedNodes, edges }
}
+1 -2
View File
@@ -2,7 +2,6 @@ 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 }) {
@@ -166,7 +165,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 v{version}
system online v1.0.1
</span>
</motion.div>
-7
View File
@@ -83,9 +83,6 @@ 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)
@@ -163,20 +160,17 @@ function MonitorPage() {
try {
const status = await trpc.clawdbot.persistenceStatus.query()
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
setIsHydrating(true)
const data = await trpc.clawdbot.persistenceHydrate.query()
hydrateFromServer(data.sessions, data.actions, data.execEvents ?? [])
console.log(
`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions, ${(data.execEvents ?? []).length} exec events`
)
setIsHydrating(false)
}
setPersistenceEnabled(status.enabled)
setPersistenceStartedAt(status.startedAt)
setPersistenceSessionCount(status.sessionCount)
setPersistenceActionCount(status.actionCount)
} catch (e) {
setIsHydrating(false)
console.error('Failed to hydrate:', e)
}
}
@@ -505,7 +499,6 @@ function MonitorPage() {
execs={execs}
selectedSession={selectedSession}
onSessionSelect={setSelectedSession}
isHydrating={isHydrating}
/>
</div>
</div>
+14 -16
View File
@@ -1,18 +1,16 @@
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';
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'
export default defineConfig({
plugins: [
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
nitro(),
viteReact(),
],
});
plugins: [
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})