mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 17:12:05 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84182b2e4 | ||
|
|
80ddf4d821 | ||
|
|
5af254087c | ||
|
|
f9dbd9b0bf | ||
|
|
bddf8b81a6 | ||
|
|
8db30f1f12 | ||
|
|
3c370e3595 | ||
|
|
8d417e2d18 | ||
|
|
aded6fa1e5 | ||
|
|
3c36ba059a | ||
|
|
854329e5fd | ||
|
|
1fc757c732 | ||
|
|
e2922a08fa | ||
|
|
3c29e63d44 | ||
|
|
2ce840b957 | ||
|
|
1e52901b81 | ||
|
|
1c0388c586 | ||
|
|
eabebfd761 | ||
|
|
80c28485f5 |
@@ -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
|
||||
|
||||
Generated
+1221
-108
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -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",
|
||||
@@ -26,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",
|
||||
@@ -36,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,12 +6,16 @@ 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'
|
||||
@@ -31,6 +35,7 @@ interface ActionGraphProps {
|
||||
execs: MonitorExecProcess[]
|
||||
selectedSession: string | null
|
||||
onSessionSelect: (key: string | null) => void
|
||||
isHydrating?: boolean
|
||||
}
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
@@ -78,6 +83,7 @@ function ActionGraphInner({
|
||||
execs,
|
||||
selectedSession,
|
||||
onSessionSelect,
|
||||
isHydrating = false,
|
||||
}: ActionGraphProps) {
|
||||
// Crab AI state
|
||||
const crabRef = useRef<CrabAI>({
|
||||
@@ -91,9 +97,33 @@ 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)
|
||||
@@ -334,22 +364,51 @@ function ActionGraphInner({
|
||||
return edges
|
||||
}, [sessions, visibleActions, visibleExecs, selectedSession])
|
||||
|
||||
// Apply layout
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||
if (rawNodes.length === 1) {
|
||||
return {
|
||||
nodes: [{ ...rawNodes[0]!, position: { x: 0, y: 0 } }],
|
||||
edges: [],
|
||||
// Apply layout - fast path for small graphs, transition for large graphs
|
||||
const LARGE_GRAPH_THRESHOLD = 100
|
||||
|
||||
// Fast path for small graphs (synchronous)
|
||||
const immediateLayout = useMemo(() => {
|
||||
if (rawNodes.length < LARGE_GRAPH_THRESHOLD) {
|
||||
if (rawNodes.length === 1) {
|
||||
return {
|
||||
nodes: [{ ...rawNodes[0]!, position: { x: 0, y: 0 } }],
|
||||
edges: [],
|
||||
}
|
||||
}
|
||||
return layoutGraph(rawNodes, rawEdges, {
|
||||
direction: layoutDirection,
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 80,
|
||||
rankSep: 60,
|
||||
nodeSep: 30,
|
||||
})
|
||||
}
|
||||
return layoutGraph(rawNodes, rawEdges, {
|
||||
direction: 'TB',
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 80,
|
||||
rankSep: 60,
|
||||
nodeSep: 30,
|
||||
})
|
||||
}, [rawNodes, rawEdges])
|
||||
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(() => {
|
||||
@@ -370,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(() => {
|
||||
@@ -433,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')) {
|
||||
@@ -446,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
|
||||
@@ -465,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(() => {
|
||||
@@ -642,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',
|
||||
@@ -670,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 () => {
|
||||
@@ -711,6 +818,60 @@ 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'
|
||||
@@ -726,7 +887,7 @@ function ActionGraphInner({
|
||||
}}
|
||||
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,
|
||||
|
||||
@@ -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 break-words">
|
||||
<pre className="font-console text-[11px] whitespace-pre-wrap wrap-break-workds">
|
||||
{chunk.text}
|
||||
</pre>
|
||||
</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,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>
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
// Track runId → sessionKey mapping (learned from chat events)
|
||||
const runSessionMap = new Map<string, string>()
|
||||
|
||||
// Track recent activity on parent (non-subagent) sessions for spawn inference
|
||||
// Maps sessionKey → lastActivityTimestamp
|
||||
const parentSessionActivity = new Map<string, number>()
|
||||
// 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 - parent must have been active within this window
|
||||
const SPAWN_INFERENCE_WINDOW_MS = 5000
|
||||
// Time window for spawn inference
|
||||
const SPAWN_INFERENCE_WINDOW_MS = 10000
|
||||
|
||||
function isSubagentSession(key: string): boolean {
|
||||
return key.includes('subagent')
|
||||
@@ -27,31 +28,58 @@ function isParentSession(key: string): boolean {
|
||||
return !isSubagentSession(key) && !key.includes('lifecycle')
|
||||
}
|
||||
|
||||
// Infer which parent session spawned this subagent based on recent activity
|
||||
// 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 now = timestamp ?? Date.now()
|
||||
const subagentTime = timestamp ?? Date.now()
|
||||
const cutoff = subagentTime - SPAWN_INFERENCE_WINDOW_MS
|
||||
|
||||
let bestParent: string | undefined
|
||||
let bestTime = 0
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestParent
|
||||
}
|
||||
if (bestParent) {
|
||||
console.log(`[spawn] linked ${subagentKey} to ${bestParent} (action ${subagentTime - bestTime}ms before)`)
|
||||
} else {
|
||||
console.log(`[spawn] could not infer parent for ${subagentKey}`)
|
||||
}
|
||||
|
||||
// Track activity on a parent session
|
||||
function trackParentActivity(sessionKey: string, timestamp?: number) {
|
||||
if (!isParentSession(sessionKey)) return
|
||||
parentSessionActivity.set(sessionKey, timestamp ?? Date.now())
|
||||
return bestParent
|
||||
}
|
||||
|
||||
export const sessionsCollection = createCollection(
|
||||
@@ -164,11 +192,6 @@ 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) {
|
||||
@@ -194,10 +217,9 @@ export function upsertSession(session: MonitorSession) {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -208,9 +230,9 @@ export function addAction(action: MonitorAction) {
|
||||
backfillExecSessionKey(action.runId, action.sessionKey)
|
||||
}
|
||||
|
||||
// Track activity on parent sessions for spawn inference
|
||||
// Track parent session actions for spawn inference
|
||||
if (isParentSession(action.sessionKey)) {
|
||||
trackParentActivity(action.sessionKey, action.timestamp)
|
||||
trackParentAction(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,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
|
||||
}
|
||||
|
||||
@@ -395,12 +393,6 @@ 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) => {
|
||||
@@ -437,7 +429,7 @@ export function updateSession(key: string, update: Partial<MonitorSession>) {
|
||||
// Clear all data
|
||||
export function clearCollections() {
|
||||
runSessionMap.clear()
|
||||
parentSessionActivity.clear()
|
||||
parentActionHistory.clear()
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
sessionsCollection.delete(session.key)
|
||||
}
|
||||
@@ -503,23 +495,24 @@ export function hydrateFromServer(
|
||||
// First clear existing data
|
||||
clearCollections()
|
||||
|
||||
// Replay actions first to build parent activity history
|
||||
// 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) {
|
||||
// Track parent activity without inserting actions yet
|
||||
if (action.sessionKey && isParentSession(action.sessionKey)) {
|
||||
trackParentActivity(action.sessionKey, action.timestamp)
|
||||
trackParentAction(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Also track parent sessions by their lastActivityAt
|
||||
for (const session of sessions) {
|
||||
if (isParentSession(session.key)) {
|
||||
trackParentActivity(session.key, session.lastActivityAt)
|
||||
trackParentAction(session.key, session.lastActivityAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Now insert all sessions - subagents will get inferred spawnedBy
|
||||
// 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)
|
||||
|
||||
@@ -113,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') {
|
||||
@@ -128,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 || '')
|
||||
}
|
||||
@@ -177,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),
|
||||
@@ -194,6 +190,31 @@ 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
|
||||
}
|
||||
|
||||
|
||||
+121
-35
@@ -31,11 +31,18 @@ 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
|
||||
@@ -46,16 +53,19 @@ interface SessionColumn {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 = {}
|
||||
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')
|
||||
@@ -75,8 +85,42 @@ export function layoutGraph(
|
||||
const sessionColumns = new Map<string, SessionColumn>()
|
||||
const columnOccupancy = new Map<number, number>() // columnIndex -> maxY used
|
||||
|
||||
// First pass: determine column for each session based on spawn hierarchy
|
||||
const getSessionColumn = (sessionKey: string, visited = new Set<string>()): number => {
|
||||
// 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)
|
||||
|
||||
@@ -84,18 +128,20 @@ export function layoutGraph(
|
||||
if (!session) return 0
|
||||
|
||||
if (session.spawnedBy) {
|
||||
return getSessionColumn(session.spawnedBy, visited) + 1
|
||||
return getSessionDepth(session.spawnedBy, visited) + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Assign columns to all sessions
|
||||
for (const session of sessions) {
|
||||
const columnIndex = getSessionColumn(session.key)
|
||||
const depth = getSessionDepth(session.key)
|
||||
const rootIndex = findRootIndex(session.key)
|
||||
sessionColumns.set(session.key, {
|
||||
sessionKey: session.key,
|
||||
columnIndex,
|
||||
spawnY: 0,
|
||||
columnIndex: depth,
|
||||
rootIndex,
|
||||
spawnY: depth === 0 ? ROOT_START_Y : 0, // Root sessions start below crab
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
@@ -173,6 +219,7 @@ export function layoutGraph(
|
||||
|
||||
// Calculate spawn Y positions for child sessions
|
||||
// When a session is spawned, find the Y position of the parent at that time
|
||||
// Cache these values so they don't jitter as parent actions accumulate
|
||||
for (const session of sessions) {
|
||||
if (!session.spawnedBy) continue
|
||||
|
||||
@@ -180,6 +227,13 @@ export function layoutGraph(
|
||||
const childCol = sessionColumns.get(session.key)
|
||||
if (!parentCol || !childCol) continue
|
||||
|
||||
// Use cached spawn Y if available (prevents jitter from recalculation)
|
||||
const cachedSpawnY = spawnYCache.get(session.key)
|
||||
if (cachedSpawnY !== undefined) {
|
||||
childCol.spawnY = cachedSpawnY
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the approximate position in parent where spawn happened
|
||||
// Use the child's creation time (approximated by first action time or session activity)
|
||||
const childActions = actionsBySession.get(session.key) ?? []
|
||||
@@ -197,8 +251,10 @@ export function layoutGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y based on parent's item count
|
||||
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
|
||||
// Calculate Y based on parent's item count and cache it
|
||||
const calculatedSpawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
|
||||
spawnYCache.set(session.key, calculatedSpawnY)
|
||||
childCol.spawnY = calculatedSpawnY
|
||||
}
|
||||
|
||||
// Position all nodes
|
||||
@@ -214,59 +270,81 @@ export function layoutGraph(
|
||||
positionedNodeIds.add(crabNode.id)
|
||||
}
|
||||
|
||||
// Track column usage for collision avoidance: columnIndex -> list of {startY, endY} ranges
|
||||
const columnRanges = new Map<number, Array<{ startY: number; endY: number }>>()
|
||||
// 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 }>>()
|
||||
|
||||
// Get X position for a column (all nodes in same column share same X)
|
||||
const getColumnX = (columnIndex: number): number => {
|
||||
return columnIndex * COLUMN_GAP
|
||||
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 = (columnIndex: number, desiredY: number, itemCount: number): number => {
|
||||
const ranges = columnRanges.get(columnIndex) ?? []
|
||||
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 our desired position overlaps with an existing range
|
||||
if (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) {
|
||||
// Shift below this range with minimum gap
|
||||
adjustedY = range.endY + MIN_SESSION_GAP
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Record our range
|
||||
ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight })
|
||||
columnRanges.set(columnIndex, ranges)
|
||||
|
||||
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)!.columnIndex
|
||||
const colB = sessionColumns.get(b)!.columnIndex
|
||||
if (colA !== colB) return colA - colB
|
||||
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 sessionColumns.get(a)!.spawnY - sessionColumns.get(b)!.spawnY
|
||||
return colA.spawnY - colB.spawnY
|
||||
})
|
||||
|
||||
// Position each session's column
|
||||
for (const sessionKey of sortedSessionKeys) {
|
||||
const col = sessionColumns.get(sessionKey)!
|
||||
const columnX = getColumnX(col.columnIndex)
|
||||
|
||||
const columnX = getColumnX(col.rootIndex, col.columnIndex)
|
||||
|
||||
// Adjust Y position to avoid collisions with other sessions in same column
|
||||
const adjustedY = adjustSpawnY(col.columnIndex, col.spawnY, col.items.length)
|
||||
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,
|
||||
@@ -298,6 +376,14 @@ export function layoutGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up spawn Y cache for sessions that no longer exist
|
||||
const currentSessionKeys = new Set(sessions.map(s => s.key))
|
||||
for (const key of spawnYCache.keys()) {
|
||||
if (!currentSessionKeys.has(key)) {
|
||||
spawnYCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: positionedNodes, edges }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ function MonitorPage() {
|
||||
// Settings panel state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Hydrating state for large graph loading
|
||||
const [isHydrating, setIsHydrating] = useState(false)
|
||||
|
||||
// Live queries from TanStack DB collections
|
||||
const sessionsQuery = useLiveQuery(sessionsCollection)
|
||||
const actionsQuery = useLiveQuery(actionsCollection)
|
||||
@@ -160,17 +163,20 @@ function MonitorPage() {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
|
||||
setIsHydrating(true)
|
||||
const data = await trpc.clawdbot.persistenceHydrate.query()
|
||||
hydrateFromServer(data.sessions, data.actions, data.execEvents ?? [])
|
||||
console.log(
|
||||
`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions, ${(data.execEvents ?? []).length} exec events`
|
||||
)
|
||||
setIsHydrating(false)
|
||||
}
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch (e) {
|
||||
setIsHydrating(false)
|
||||
console.error('Failed to hydrate:', e)
|
||||
}
|
||||
}
|
||||
@@ -499,6 +505,7 @@ function MonitorPage() {
|
||||
execs={execs}
|
||||
selectedSession={selectedSession}
|
||||
onSessionSelect={setSelectedSession}
|
||||
isHydrating={isHydrating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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