mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 09:02:07 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5636167c57 | ||
|
|
5878b15be1 | ||
|
|
c84fc7a2fb | ||
|
|
c001f612b9 | ||
|
|
0b0613e08a | ||
|
|
551543682f | ||
|
|
cf8ac7ca67 | ||
|
|
27d0afd736 | ||
|
|
efb1790964 | ||
|
|
e58e37ca87 | ||
|
|
e19a58054d | ||
|
|
4ef95e6df1 | ||
|
|
87a6e71aa9 | ||
|
|
3ba5b7ee6c | ||
|
|
7e75a12614 | ||
|
|
b91bfeb4a4 | ||
|
|
e0b5ddfec5 | ||
|
|
154c93131e | ||
|
|
50114100a2 | ||
|
|
2ba3ef6d47 | ||
|
|
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=
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Create build artifact
|
||||
run: |
|
||||
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz dist
|
||||
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz .output bin package.json
|
||||
|
||||
- name: Upload build to release
|
||||
uses: softprops/action-gh-release@v1
|
||||
|
||||
@@ -37,3 +37,6 @@ documents/*
|
||||
|
||||
# Persistence data
|
||||
data/
|
||||
|
||||
# coding agent plans
|
||||
plans/
|
||||
@@ -36,15 +36,15 @@ Full-stack React app using TanStack Start (file-based routing, SSR).
|
||||
|
||||
**TanStack DB pattern:** Create collections, use `useLiveQuery()` for reactive reads, `createTransaction()` for writes.
|
||||
|
||||
## Moltbot (Clawdbot) Monitor
|
||||
## OpenClaw (Clawdbot) Monitor
|
||||
|
||||
Real-time agent activity monitor at `/monitor`.
|
||||
|
||||
**Key paths:**
|
||||
- `src/integrations/clawdbot/` - gateway client, protocol types, parser, collections
|
||||
- `src/integrations/openclaw/` - gateway client, protocol types, parser, collections
|
||||
- `src/components/monitor/` - ReactFlow graph, session list, custom nodes
|
||||
- `src/routes/monitor/index.tsx` - main monitor page
|
||||
|
||||
**Data flow:** clawdbot gateway (ws://127.0.0.1:18789) -> TanStack Start server (WS client) -> tRPC -> browser (TanStack DB collections -> ReactFlow)
|
||||
**Data flow:** openclaw gateway (ws://127.0.0.1:18789) -> TanStack Start server (WS client) -> tRPC -> browser (TanStack DB collections -> ReactFlow)
|
||||
|
||||
**Config:** Set `CLAWDBOT_API_TOKEN` env var for gateway auth.
|
||||
|
||||
+8
-13
@@ -1,6 +1,4 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
@@ -9,22 +7,19 @@ 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 HOME=/root
|
||||
|
||||
ENV NODE_ENV=development
|
||||
# Create workspace directory for volume mounting
|
||||
RUN mkdir -p /root/.openclaw/workspace
|
||||
|
||||
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"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🦀 Crabwalk
|
||||
|
||||
Real-time companion monitor for [Moltbot (Clawdbot)](https://github.com/moltbot/moltbot) agents by [@luccasveg](https://x.com/luccasveg).
|
||||
Real-time companion monitor for [OpenClaw (Clawdbot)](https://github.com/openclaw/openclaw) agents by [@luccasveg](https://x.com/luccasveg).
|
||||
|
||||
Watch your AI agents work across WhatsApp, Telegram, Discord, and Slack in a live node graph. See thinking states, tool calls, and response chains as they happen.
|
||||
|
||||
@@ -12,12 +12,41 @@ Watch your AI agents work across WhatsApp, Telegram, Discord, and Slack in a liv
|
||||
|
||||
- **Live activity graph** - ReactFlow visualization of agent sessions and action chains
|
||||
- **Multi-platform** - Monitor agents across all messaging platforms simultaneously
|
||||
- **Real-time streaming** - WebSocket connection to clawdbot gateway
|
||||
- **Real-time streaming** - WebSocket connection to openclaw gateway
|
||||
- **Action tracing** - Expand nodes to inspect tool args and payloads
|
||||
- **Session filtering** - Filter by platform, search by recipient
|
||||
|
||||
## Installation
|
||||
|
||||
### Via OpenClaw Agent
|
||||
|
||||
Paste this link to your OpenClaw agent and ask it to install/update Crabwalk:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/luccast/crabwalk/master/public/skill.md
|
||||
```
|
||||
|
||||
### CLI Install
|
||||
|
||||
```bash
|
||||
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||
mkdir -p ~/.crabwalk ~/.local/bin
|
||||
curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk
|
||||
cp ~/.crabwalk/bin/crabwalk ~/.local/bin/
|
||||
chmod +x ~/.local/bin/crabwalk
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
crabwalk # Start on 0.0.0.0:3000
|
||||
crabwalk start --daemon # Run in background
|
||||
crabwalk start -p 8080 # Custom port
|
||||
crabwalk stop # Stop daemon
|
||||
crabwalk status # Check if running
|
||||
crabwalk update # Update to latest
|
||||
```
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
@@ -25,19 +54,50 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-e CLAWDBOT_URL=ws://host.docker.internal:18789 \
|
||||
-v ~/.openclaw/workspace:/root/.openclaw/workspace \
|
||||
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 OpenClaw gateway typically runs on the _host_.
|
||||
> Use `CLAWDBOT_URL=ws://host.docker.internal:18789` so the container can connect.
|
||||
> If you're running OpenClaw 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.
|
||||
|
||||
#### Workspace Access
|
||||
|
||||
The workspace explorer needs access to your local files. By default, it looks for files at `~/.openclaw/workspace`. In Docker, mount your host workspace to the same path in the container:
|
||||
|
||||
```bash
|
||||
# Default workspace path (recommended)
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-v ~/.openclaw/workspace:/root/.openclaw/workspace \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
|
||||
# Custom workspace path on host
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-v /path/to/your/workspace:/root/.openclaw/workspace \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
```
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/luccast/crabwalk/master/docker-compose.yml
|
||||
CLAWDBOT_API_TOKEN=your-token CLAWDBOT_URL=ws://host.docker.internal:18789 docker-compose up -d
|
||||
CLAWDBOT_API_TOKEN=your-token docker-compose up -d
|
||||
```
|
||||
|
||||
To use a custom workspace path with docker-compose, set the `WORKSPACE_HOST_PATH` environment variable:
|
||||
|
||||
```bash
|
||||
WORKSPACE_HOST_PATH=/path/to/your/workspace CLAWDBOT_API_TOKEN=your-token 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
|
||||
@@ -51,27 +111,27 @@ Open `http://localhost:3000/monitor`
|
||||
|
||||
## Configuration
|
||||
|
||||
Requires clawdbot gateway running on the same machine.
|
||||
Requires OpenClaw gateway running on the same machine.
|
||||
|
||||
### Gateway Token
|
||||
|
||||
Find your token in the clawdbot config file:
|
||||
Find your token in the openclaw config file:
|
||||
|
||||
```bash
|
||||
# Look for gateway.auth.token
|
||||
cat ~/.clawdbot/clawdbot.json | rg "gateway\.auth\.token"
|
||||
cat ~/.openclaw/openclaw.json | rg "gateway\.auth\.token"
|
||||
```
|
||||
|
||||
Or with jq:
|
||||
|
||||
```bash
|
||||
jq '.gateway.auth.token' ~/.clawdbot/clawdbot.json
|
||||
jq '.gateway.auth.token' ~/.openclaw/openclaw.json
|
||||
```
|
||||
|
||||
Or copy it directly:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.clawdbot/clawdbot.json')))['gateway']['auth']['token'])")
|
||||
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.openclaw/openclaw.json')))['gateway']['auth']['token'])")
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env bash
|
||||
# 🦀 Crabwalk CLI
|
||||
|
||||
set -e
|
||||
|
||||
CRABWALK_HOME="${CRABWALK_HOME:-$HOME/.crabwalk}"
|
||||
PID_FILE="$CRABWALK_HOME/crabwalk.pid"
|
||||
LOG_FILE="$CRABWALK_HOME/crabwalk.log"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get version from package.json
|
||||
get_version() {
|
||||
cat "$CRABWALK_HOME/package.json" 2>/dev/null | grep '"version"' | cut -d'"' -f4
|
||||
}
|
||||
|
||||
# Auto-detect token from OpenClaw config
|
||||
auto_token() {
|
||||
if [ -f "$HOME/.openclaw/openclaw.json" ]; then
|
||||
python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.openclaw/openclaw.json')))['gateway']['auth']['token'])" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Get network IPs
|
||||
get_network_ips() {
|
||||
if command -v hostname &>/dev/null; then
|
||||
hostname -I 2>/dev/null | tr ' ' '\n' | grep -v '^$' || true
|
||||
elif command -v ip &>/dev/null; then
|
||||
ip -4 addr show 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127.0.0.1' || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Show QR code for URL
|
||||
show_qr() {
|
||||
local url="$1"
|
||||
if command -v qrencode &>/dev/null; then
|
||||
echo ""
|
||||
qrencode -t ANSIUTF8 -m 2 "$url" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if running
|
||||
is_running() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "$pid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Show startup banner
|
||||
show_banner() {
|
||||
local port="${1:-3000}"
|
||||
local host="${2:-0.0.0.0}"
|
||||
local version=$(get_version)
|
||||
local network_url=""
|
||||
|
||||
echo ""
|
||||
echo -e "🦀 ${GREEN}Crabwalk${NC} v${version}"
|
||||
echo ""
|
||||
|
||||
if [ "$host" = "0.0.0.0" ]; then
|
||||
echo -e " ➜ Local: ${CYAN}http://localhost:${port}/monitor${NC}"
|
||||
for ip in $(get_network_ips); do
|
||||
# Use first IPv4 for QR code
|
||||
if [ -z "$network_url" ] && [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
network_url="http://${ip}:${port}/monitor"
|
||||
fi
|
||||
echo -e " ➜ Network: ${CYAN}http://${ip}:${port}/monitor${NC}"
|
||||
done
|
||||
else
|
||||
network_url="http://${host}:${port}/monitor"
|
||||
echo -e " ➜ Server: ${CYAN}${network_url}${NC}"
|
||||
fi
|
||||
|
||||
# Show QR code for network access
|
||||
if [ -n "$network_url" ]; then
|
||||
show_qr "$network_url"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
local version=$(get_version)
|
||||
echo "🦀 Crabwalk v${version:-unknown}"
|
||||
echo ""
|
||||
echo "Usage: crabwalk <command> [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start Start server (default)"
|
||||
echo " stop Stop running server"
|
||||
echo " status Check if server is running"
|
||||
echo " update Update to latest version"
|
||||
echo " help Show this help"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -p, --port <port> Server port (default: 3000)"
|
||||
echo " -H, --host <host> Bind address (default: 0.0.0.0)"
|
||||
echo " -g, --gateway <url> Gateway WebSocket URL"
|
||||
echo " -t, --token <token> Gateway auth token"
|
||||
echo " -d, --daemon Run in background"
|
||||
echo " -v, --version Show version"
|
||||
echo " -h, --help Show help"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " crabwalk # Start on 0.0.0.0:3000"
|
||||
echo " crabwalk start --daemon # Run in background"
|
||||
echo " crabwalk start -p 8080 # Custom port"
|
||||
echo " crabwalk stop # Stop daemon"
|
||||
echo " crabwalk status # Check status"
|
||||
echo " crabwalk update # Update to latest"
|
||||
}
|
||||
|
||||
# Start command
|
||||
cmd_start() {
|
||||
local port="${PORT:-3000}"
|
||||
local host="${HOST:-0.0.0.0}"
|
||||
local daemon=false
|
||||
local token="${CLAWDBOT_API_TOKEN}"
|
||||
local gateway="${CLAWDBOT_URL}"
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-p|--port) port="$2"; shift 2 ;;
|
||||
-H|--host) host="$2"; shift 2 ;;
|
||||
-t|--token) token="$2"; shift 2 ;;
|
||||
-g|--gateway) gateway="$2"; shift 2 ;;
|
||||
-d|--daemon) daemon=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-detect token if not provided
|
||||
if [ -z "$token" ]; then
|
||||
token=$(auto_token)
|
||||
fi
|
||||
|
||||
# Check if already running
|
||||
if pid=$(is_running); then
|
||||
echo -e "🦀 Crabwalk is already running (PID $pid)"
|
||||
echo -e " Use ${YELLOW}crabwalk stop${NC} first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if crabwalk is installed
|
||||
if [ ! -f "$CRABWALK_HOME/.output/server/index.mjs" ]; then
|
||||
echo -e "${RED}Error:${NC} Crabwalk not found at $CRABWALK_HOME"
|
||||
echo "Install with: https://raw.githubusercontent.com/luccast/crabwalk/master/public/skill.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build env vars
|
||||
export PORT="$port"
|
||||
export HOST="$host"
|
||||
[ -n "$token" ] && export CLAWDBOT_API_TOKEN="$token"
|
||||
[ -n "$gateway" ] && export CLAWDBOT_URL="$gateway"
|
||||
|
||||
if [ "$daemon" = true ]; then
|
||||
# Run in background
|
||||
nohup node "$CRABWALK_HOME/.output/server/index.mjs" > "$LOG_FILE" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
|
||||
sleep 1
|
||||
if is_running >/dev/null; then
|
||||
show_banner "$port" "$host"
|
||||
echo -e "🦀 Running in background (PID $(cat $PID_FILE))"
|
||||
echo -e " Logs: ${CYAN}$LOG_FILE${NC}"
|
||||
else
|
||||
echo -e "${RED}Error:${NC} Failed to start. Check $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Run in foreground
|
||||
show_banner "$port" "$host"
|
||||
echo -e "Press ${YELLOW}Ctrl+C${NC} to stop"
|
||||
echo ""
|
||||
exec node "$CRABWALK_HOME/.output/server/index.mjs"
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop command
|
||||
cmd_stop() {
|
||||
if pid=$(is_running); then
|
||||
kill "$pid" 2>/dev/null
|
||||
rm -f "$PID_FILE"
|
||||
echo "🦀 Crabwalk stopped"
|
||||
else
|
||||
echo "🦀 Crabwalk is not running"
|
||||
fi
|
||||
}
|
||||
|
||||
# Status command
|
||||
cmd_status() {
|
||||
local port="${PORT:-3000}"
|
||||
|
||||
if pid=$(is_running); then
|
||||
echo -e "🦀 Crabwalk is ${GREEN}running${NC} (PID $pid)"
|
||||
echo -e " http://localhost:${port}/monitor"
|
||||
else
|
||||
echo -e "🦀 Crabwalk is ${RED}stopped${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Update command
|
||||
cmd_update() {
|
||||
local installed=$(get_version)
|
||||
local latest=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4 | tr -d 'v')
|
||||
|
||||
if [ -z "$installed" ]; then
|
||||
echo "🦀 Crabwalk not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$latest" ]; then
|
||||
echo -e "${RED}Error:${NC} Could not fetch latest version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$installed" = "$latest" ]; then
|
||||
echo "🦀 Already on latest: $installed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🦀 Update available: $installed -> $latest"
|
||||
echo " https://github.com/luccast/crabwalk/releases/tag/v${latest}"
|
||||
echo ""
|
||||
read -p "Update now? [y/N] " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# Stop if running
|
||||
if is_running >/dev/null; then
|
||||
echo "🦀 Stopping server..."
|
||||
cmd_stop
|
||||
fi
|
||||
|
||||
echo "🦀 Downloading v${latest}..."
|
||||
local version="v${latest}"
|
||||
rm -rf "$CRABWALK_HOME/.output"
|
||||
curl -sL "https://github.com/luccast/crabwalk/releases/download/${version}/crabwalk-${version}.tar.gz" | tar -xz -C "$CRABWALK_HOME"
|
||||
|
||||
# Update CLI if needed
|
||||
if [ -f "$CRABWALK_HOME/bin/crabwalk" ]; then
|
||||
cp "$CRABWALK_HOME/bin/crabwalk" ~/.local/bin/crabwalk 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "🦀 Updated to v${latest}"
|
||||
else
|
||||
echo "🦀 Update cancelled"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
local cmd="${1:-start}"
|
||||
shift 2>/dev/null || true
|
||||
|
||||
# Handle flags before command
|
||||
case $cmd in
|
||||
-v|--version)
|
||||
echo "🦀 Crabwalk v$(get_version)"
|
||||
exit 0
|
||||
;;
|
||||
-h|--help|help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
start)
|
||||
cmd_start "$@"
|
||||
;;
|
||||
stop)
|
||||
cmd_stop
|
||||
;;
|
||||
status)
|
||||
cmd_status
|
||||
;;
|
||||
update)
|
||||
cmd_update
|
||||
;;
|
||||
*)
|
||||
# Assume it's an option for start
|
||||
cmd_start "$cmd" "$@"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -5,4 +5,9 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- CLAWDBOT_API_TOKEN=${CLAWDBOT_API_TOKEN}
|
||||
volumes:
|
||||
# Mount host workspace directory to container
|
||||
# The container expects the workspace at ~/.openclaw/workspace
|
||||
# Change the host path if your workspace is in a different location
|
||||
- ${WORKSPACE_HOST_PATH:-~/.openclaw/workspace}:/root/.openclaw/workspace
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Overview
|
||||
Repo: clawdbot/clawdbot
|
||||
Repo: openclaw/openclaw
|
||||
Ref: 50b4126c79536a9645cddcfe6801916b5f6d9343
|
||||
Base URL: https://codebase.md/clawdbot/clawdbot
|
||||
Base URL: https://codebase.md/openclaw/openclaw
|
||||
Note: This repo is currently being built in the background.
|
||||
Status: /status/clawdbot/clawdbot/50b4126c79536a9645cddcfe6801916b5f6d9343
|
||||
Status: /status/openclaw/openclaw/50b4126c79536a9645cddcfe6801916b5f6d9343
|
||||
Estimated build time: ~96 seconds.
|
||||
Try again in 60 seconds.
|
||||
|
||||
Generated
+1407
-266
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000 --host",
|
||||
"build": "vite build",
|
||||
"start": "node dist/server/server.js"
|
||||
"start": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/db": "^0.5.0",
|
||||
"@tanstack/history": "^1.132.0",
|
||||
"@tanstack/react-db": "^0.1.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
@@ -17,9 +18,7 @@
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
@@ -28,8 +27,7 @@
|
||||
"superjson": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.24.0",
|
||||
"@tanstack/history": "^1.132.0"
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -38,8 +36,12 @@
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"nitro": "npm:nitro-nightly@^3.0.1-20260128-211656-ae83c97e",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4245
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- bufferutil
|
||||
- esbuild
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.8",
|
||||
"description": "Real-time companion monitor for OpenClaw agents",
|
||||
"homepage": "https://crabwalk.app",
|
||||
"repository": "https://github.com/luccast/crabwalk",
|
||||
"metadata": {
|
||||
"emoji": "🦀",
|
||||
"category": "monitoring"
|
||||
},
|
||||
"install": {
|
||||
"method": "tarball",
|
||||
"location": "~/.crabwalk",
|
||||
"cli": "~/.local/bin/crabwalk",
|
||||
"start_command": "crabwalk"
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: crabwalk
|
||||
version: 1.0.8
|
||||
description: Real-time companion monitor for OpenClaw agents
|
||||
homepage: https://crabwalk.app
|
||||
repository: https://github.com/luccast/crabwalk
|
||||
metadata: {"emoji":"🦀","category":"monitoring"}
|
||||
---
|
||||
|
||||
# Crabwalk 🦀
|
||||
|
||||
Real-time companion monitor for OpenClaw agents.
|
||||
|
||||
# Required Steps
|
||||
|
||||
## Step 1: Install
|
||||
|
||||
```bash
|
||||
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && (command -v qrencode &>/dev/null || (command -v pacman &>/dev/null && sudo pacman -S --noconfirm qrencode || command -v apt &>/dev/null && sudo apt install -y qrencode || command -v brew &>/dev/null && brew install qrencode || command -v dnf &>/dev/null && sudo dnf install -y qrencode) 2>/dev/null || true) && echo "🦀 Crabwalk ${VERSION} installed"
|
||||
```
|
||||
|
||||
## Step 2: Verify
|
||||
|
||||
```bash
|
||||
crabwalk --version
|
||||
```
|
||||
|
||||
Should output version number (e.g., `1.0.8`).
|
||||
|
||||
## Step 3: Start
|
||||
|
||||
```bash
|
||||
crabwalk
|
||||
```
|
||||
|
||||
Server starts and displays access URLs. **Share the `http://192.x.x.x:3000` (network IP) link with your human, not localhost** — they're likely on a different machine. Visit `/monitor` to see agent activity graph.
|
||||
|
||||
## Step 4: Verify with Human
|
||||
|
||||
Ask your human: "Can you access the monitor at the link I shared?"
|
||||
|
||||
Once they confirm it works, share this with them:
|
||||
|
||||
> If you're enjoying Crabwalk, consider leaving a quick review! [Tweet about Crabwalk](https://twitter.com/intent/tweet?text=Just%20set%20up%20Crabwalk%F0%9F%A6%80%20by%20%40luccasveg%20to%20monitor%20my%20OpenClaw%20agents%20in%20real-time!%20https%3A%2F%2Fcrabwalk.app%20)
|
||||
|
||||
---
|
||||
|
||||
# Reference
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
crabwalk # Start server (0.0.0.0:3000)
|
||||
crabwalk start --daemon # Run in background
|
||||
crabwalk start -p 8080 # Custom port
|
||||
crabwalk stop # Stop background server
|
||||
crabwalk status # Check if running
|
||||
crabwalk update # Update to latest version
|
||||
crabwalk --help # Show all options
|
||||
```
|
||||
|
||||
## CLI Options
|
||||
|
||||
```
|
||||
Options:
|
||||
-p, --port <port> Server port (default: 3000)
|
||||
-H, --host <host> Bind address (default: 0.0.0.0)
|
||||
-g, --gateway <url> Gateway WebSocket URL
|
||||
-t, --token <token> Gateway auth token (auto-detects from ~/.openclaw/openclaw.json)
|
||||
-d, --daemon Run in background
|
||||
-v, --version Show version
|
||||
-h, --help Show help
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `/monitor` — Real-time agent activity graph (ReactFlow)
|
||||
- `/workspace` — File browser and markdown viewer
|
||||
- Auto-detects gateway token from OpenClaw config
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- OpenClaw gateway running on `ws://127.0.0.1:18789`
|
||||
- `qrencode` (optional, for QR code display)
|
||||
|
||||
## Updating
|
||||
|
||||
**IMPORTANT: Always ask user permission before updating.**
|
||||
|
||||
If update available, ask user:
|
||||
> "Crabwalk update available (X.X.X -> Y.Y.Y). See release: https://github.com/luccast/crabwalk/releases/tag/vY.Y.Y — Update now?"
|
||||
|
||||
Only proceed if user confirms. Run:
|
||||
|
||||
```bash
|
||||
crabwalk update
|
||||
```
|
||||
|
||||
## Install Script (Expanded)
|
||||
|
||||
```bash
|
||||
# Download latest release
|
||||
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||
mkdir -p ~/.crabwalk
|
||||
curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk
|
||||
|
||||
# Install CLI
|
||||
mkdir -p ~/.local/bin
|
||||
cp ~/.crabwalk/bin/crabwalk ~/.local/bin/crabwalk
|
||||
chmod +x ~/.local/bin/crabwalk
|
||||
|
||||
# Add to PATH if needed
|
||||
if ! echo $PATH | grep -q "$HOME/.local/bin"; then
|
||||
[ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
[ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Install qrencode for QR code display (optional)
|
||||
if ! command -v qrencode &>/dev/null; then
|
||||
if command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm qrencode 2>/dev/null || true
|
||||
elif command -v apt &>/dev/null; then
|
||||
sudo apt install -y qrencode 2>/dev/null || true
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install qrencode 2>/dev/null || true
|
||||
elif command -v dnf &>/dev/null; then
|
||||
sudo dnf install -y qrencode 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
## Manual Update
|
||||
|
||||
If `crabwalk update` fails:
|
||||
|
||||
```bash
|
||||
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||
rm -rf ~/.crabwalk/.output
|
||||
curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk
|
||||
cp ~/.crabwalk/bin/crabwalk ~/.local/bin/crabwalk
|
||||
echo "🦀 Updated to ${VERSION}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Repository: https://github.com/luccast/crabwalk
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -6,27 +6,42 @@ import {
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
useOnViewportChange,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
type NodeChange,
|
||||
MarkerType,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react'
|
||||
import { LayoutGrid, ArrowRightLeft, ArrowUpDown, Crosshair } from 'lucide-react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { SessionNode } from './SessionNode'
|
||||
import { ActionNode } from './ActionNode'
|
||||
import { ExecNode } from './ExecNode'
|
||||
import { CrabNode } from './CrabNode'
|
||||
import { ChaserCrabNode, type ChaserCrabState } from './ChaserCrabNode'
|
||||
import { layoutGraph } from '~/lib/graph-layout'
|
||||
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
|
||||
import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecProcess,
|
||||
} from '~/integrations/openclaw'
|
||||
|
||||
interface ActionGraphProps {
|
||||
sessions: MonitorSession[]
|
||||
actions: MonitorAction[]
|
||||
execs: MonitorExecProcess[]
|
||||
selectedSession: string | null
|
||||
onSessionSelect: (key: string | null) => void
|
||||
}
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
function nodeData<T>(data: T): Record<string, unknown> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
const CRAB_NODE_ID = 'crab-origin'
|
||||
const CHASER_CRAB_ID = 'chaser-crab'
|
||||
|
||||
@@ -46,6 +61,7 @@ const SIDEWAYS_DRIFT = 0.4 // crabs scuttle sideways
|
||||
const nodeTypes: NodeTypes = {
|
||||
session: SessionNode as any,
|
||||
action: ActionNode as any,
|
||||
exec: ExecNode as any,
|
||||
crab: CrabNode as any,
|
||||
chaserCrab: ChaserCrabNode as any,
|
||||
}
|
||||
@@ -63,6 +79,7 @@ interface CrabAI {
|
||||
function ActionGraphInner({
|
||||
sessions,
|
||||
actions,
|
||||
execs,
|
||||
selectedSession,
|
||||
onSessionSelect,
|
||||
}: ActionGraphProps) {
|
||||
@@ -78,20 +95,46 @@ 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)
|
||||
|
||||
// Get ReactFlow instance for viewport control
|
||||
const { setCenter } = useReactFlow()
|
||||
|
||||
// Detect manual panning and auto-disable follow mode
|
||||
useOnViewportChange({
|
||||
onEnd: useCallback(() => {
|
||||
if (followMode && !isAnimatingRef.current) {
|
||||
setFollowMode(false)
|
||||
}
|
||||
}, [followMode]),
|
||||
})
|
||||
|
||||
// Filter actions for selected session, or show all if none selected
|
||||
const visibleActions = useMemo(() => {
|
||||
if (!selectedSession) return actions.slice(-50)
|
||||
return actions.filter((a) => a.sessionKey === selectedSession)
|
||||
}, [actions, selectedSession])
|
||||
|
||||
const visibleExecs = useMemo(() => {
|
||||
if (!selectedSession) return execs.slice(-50)
|
||||
return execs.filter((exec) => exec.sessionKey === selectedSession)
|
||||
}, [execs, selectedSession])
|
||||
|
||||
// Build nodes
|
||||
const rawNodes = useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
|
||||
const hasActivity = sessions.length > 0 || visibleActions.length > 0
|
||||
const hasActivity =
|
||||
sessions.length > 0 || visibleActions.length > 0 || visibleExecs.length > 0
|
||||
nodes.push({
|
||||
id: CRAB_NODE_ID,
|
||||
type: 'crab',
|
||||
@@ -108,7 +151,7 @@ function ActionGraphInner({
|
||||
id: `session-${session.key}`,
|
||||
type: 'session',
|
||||
position: { x: 0, y: 0 },
|
||||
data: session as unknown as Record<string, unknown>,
|
||||
data: nodeData(session),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,12 +160,21 @@ function ActionGraphInner({
|
||||
id: `action-${action.id}`,
|
||||
type: 'action',
|
||||
position: { x: 0, y: 0 },
|
||||
data: action as unknown as Record<string, unknown>,
|
||||
data: nodeData(action),
|
||||
})
|
||||
}
|
||||
|
||||
for (const exec of visibleExecs) {
|
||||
nodes.push({
|
||||
id: `exec-${exec.id}`,
|
||||
type: 'exec',
|
||||
position: { x: 0, y: 0 },
|
||||
data: nodeData(exec),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}, [sessions, visibleActions, selectedSession])
|
||||
}, [sessions, visibleActions, visibleExecs, selectedSession])
|
||||
|
||||
// Build edges
|
||||
const rawEdges = useMemo(() => {
|
||||
@@ -132,18 +184,23 @@ function ActionGraphInner({
|
||||
? sessions.filter((s) => s.key === selectedSession)
|
||||
: sessions
|
||||
|
||||
for (const session of visibleSessions) {
|
||||
edges.push({
|
||||
id: `e-crab-${session.key}`,
|
||||
source: CRAB_NODE_ID,
|
||||
target: `session-${session.key}`,
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
style: { stroke: '#ef4444', strokeWidth: 2 },
|
||||
})
|
||||
// Build a set of visible session keys for parent lookup
|
||||
const visibleSessionKeys = new Set(visibleSessions.map((s) => s.key))
|
||||
|
||||
// Edge styles
|
||||
const spawnEdgeStyle = {
|
||||
animated: true,
|
||||
style: { stroke: '#00ffd5', strokeWidth: 2, strokeDasharray: '8 4' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
|
||||
}
|
||||
|
||||
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
|
||||
const crabEdgeStyle = {
|
||||
animated: false,
|
||||
style: { stroke: '#ef4444', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
}
|
||||
|
||||
// Group actions by session for spawn point lookup
|
||||
const sessionActions = new Map<string, MonitorAction[]>()
|
||||
for (const action of visibleActions) {
|
||||
const key = action.sessionKey
|
||||
@@ -152,6 +209,39 @@ function ActionGraphInner({
|
||||
list.push(action)
|
||||
sessionActions.set(key, list)
|
||||
}
|
||||
// Sort each session's actions by timestamp
|
||||
for (const [key, actions] of sessionActions) {
|
||||
sessionActions.set(key, [...actions].sort((a, b) => a.timestamp - b.timestamp))
|
||||
}
|
||||
|
||||
// Connect sessions to their spawn sources
|
||||
for (const session of visibleSessions) {
|
||||
const parentSessionKey = session.spawnedBy
|
||||
|
||||
if (parentSessionKey && visibleSessionKeys.has(parentSessionKey)) {
|
||||
// This session was spawned by another session
|
||||
// Connect from parent's right handle to child's left handle (horizontal spawn)
|
||||
edges.push({
|
||||
id: `e-spawn-${session.key}`,
|
||||
source: `session-${parentSessionKey}`,
|
||||
target: `session-${session.key}`,
|
||||
sourceHandle: 'spawn-source',
|
||||
targetHandle: 'spawn-target',
|
||||
type: 'smoothstep',
|
||||
...spawnEdgeStyle,
|
||||
})
|
||||
} else {
|
||||
// Root session - connect from crab
|
||||
edges.push({
|
||||
id: `e-crab-${session.key}`,
|
||||
source: CRAB_NODE_ID,
|
||||
target: `session-${session.key}`,
|
||||
...crabEdgeStyle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
|
||||
|
||||
const getEdgeStyle = (action: MonitorAction) => {
|
||||
switch (action.type) {
|
||||
@@ -194,8 +284,9 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
|
||||
// Connect actions within each session (vertical flow)
|
||||
for (const [sessionKey, actions] of sessionActions) {
|
||||
const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
const sorted = actions // Already sorted above
|
||||
const sessionId = `session-${sessionKey}`
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
@@ -203,6 +294,7 @@ function ActionGraphInner({
|
||||
const edgeStyle = getEdgeStyle(action)
|
||||
|
||||
if (i === 0) {
|
||||
// First action connects from session node
|
||||
if (sessionNodeIds.has(sessionId)) {
|
||||
edges.push({
|
||||
id: `e-session-${action.id}`,
|
||||
@@ -212,6 +304,7 @@ function ActionGraphInner({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Subsequent actions connect from previous action
|
||||
const prev = sorted[i - 1]!
|
||||
edges.push({
|
||||
id: `e-${prev.id}-${action.id}`,
|
||||
@@ -223,8 +316,47 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
|
||||
const getExecEdgeStyle = (exec: MonitorExecProcess) => {
|
||||
switch (exec.status) {
|
||||
case 'running':
|
||||
return {
|
||||
animated: true,
|
||||
style: { stroke: '#00ffd5', strokeDasharray: '4 4' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
|
||||
}
|
||||
case 'failed':
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#ef4444' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
}
|
||||
case 'completed':
|
||||
default:
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#98ffc8' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect execs to their session
|
||||
for (const exec of visibleExecs) {
|
||||
const key = exec.sessionKey
|
||||
if (!key) continue
|
||||
const sessionId = `session-${key}`
|
||||
if (!sessionNodeIds.has(sessionId)) continue
|
||||
const edgeStyle = getExecEdgeStyle(exec)
|
||||
edges.push({
|
||||
id: `e-session-exec-${exec.id}`,
|
||||
source: sessionId,
|
||||
target: `exec-${exec.id}`,
|
||||
...edgeStyle,
|
||||
})
|
||||
}
|
||||
|
||||
return edges
|
||||
}, [sessions, visibleActions, selectedSession])
|
||||
}, [sessions, visibleActions, visibleExecs, selectedSession])
|
||||
|
||||
// Apply layout
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||
@@ -235,13 +367,13 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
return layoutGraph(rawNodes, rawEdges, {
|
||||
direction: 'TB',
|
||||
direction: layoutDirection,
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 80,
|
||||
rankSep: 60,
|
||||
nodeSep: 30,
|
||||
})
|
||||
}, [rawNodes, rawEdges])
|
||||
}, [rawNodes, rawEdges, layoutDirection])
|
||||
|
||||
// Initial nodes with chaser (click handler added later)
|
||||
const initialNodes = useMemo(() => {
|
||||
@@ -262,7 +394,25 @@ function ActionGraphInner({
|
||||
return [...layoutedNodes, chaserNode]
|
||||
}, [])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||
const [nodes, setNodes, rawOnNodesChange] = useNodesState(initialNodes)
|
||||
|
||||
// Intercept node changes to detect drag-end and pin positions
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
for (const change of changes) {
|
||||
if (
|
||||
change.type === 'position' &&
|
||||
'dragging' in change &&
|
||||
change.dragging === false &&
|
||||
change.position
|
||||
) {
|
||||
pinnedPositions.current.set(change.id, { ...change.position })
|
||||
}
|
||||
}
|
||||
rawOnNodesChange(changes)
|
||||
},
|
||||
[rawOnNodesChange]
|
||||
)
|
||||
|
||||
// Handle crab click - jump animation (defined after setNodes)
|
||||
const handleCrabClick = useCallback(() => {
|
||||
@@ -325,6 +475,9 @@ function ActionGraphInner({
|
||||
const prevPositions = nodePositionsRef.current
|
||||
const crab = crabRef.current
|
||||
|
||||
// Track the latest new node for follow mode
|
||||
let latestNewNode: { x: number; y: number } | null = null
|
||||
|
||||
// Check for new nodes
|
||||
for (const node of layoutedNodes) {
|
||||
if (!node.id.includes('crab')) {
|
||||
@@ -338,7 +491,7 @@ function ActionGraphInner({
|
||||
crab.target = { ...nodeCenter, nodeId: node.id }
|
||||
crab.state = 'chasing'
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
break
|
||||
latestNewNode = nodeCenter
|
||||
}
|
||||
|
||||
// Existing node moved - if we were tracking it or idle, chase it
|
||||
@@ -357,8 +510,17 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
|
||||
// Follow mode: pan to the latest new node
|
||||
if (followMode && latestNewNode) {
|
||||
isAnimatingRef.current = true
|
||||
setCenter(latestNewNode.x, latestNewNode.y, { zoom: 0.85, duration: 500 })
|
||||
setTimeout(() => {
|
||||
isAnimatingRef.current = false
|
||||
}, 550)
|
||||
}
|
||||
|
||||
prevNodeIdsRef.current = currentIds
|
||||
}, [layoutedNodes])
|
||||
}, [layoutedNodes, followMode, setCenter])
|
||||
|
||||
// Main animation loop - step-based crab movement at 10fps timing
|
||||
useEffect(() => {
|
||||
@@ -534,16 +696,22 @@ function ActionGraphInner({
|
||||
}
|
||||
}, [layoutedNodes, setNodes, handleCrabClick])
|
||||
|
||||
// Update layout nodes when they change (preserve chaser)
|
||||
// Update layout nodes when they change (preserve chaser + pinned positions)
|
||||
useEffect(() => {
|
||||
setNodes((nds) => {
|
||||
const pinned = pinnedPositions.current
|
||||
const mergedNodes = layoutedNodes.map((n) => {
|
||||
const pin = pinned.get(n.id)
|
||||
return pin ? { ...n, position: pin } : n
|
||||
})
|
||||
|
||||
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
|
||||
if (chaserNode) {
|
||||
return [...layoutedNodes, chaserNode]
|
||||
return [...mergedNodes, chaserNode]
|
||||
}
|
||||
const crab = crabRef.current
|
||||
return [
|
||||
...layoutedNodes,
|
||||
...mergedNodes,
|
||||
{
|
||||
id: CHASER_CRAB_ID,
|
||||
type: 'chaserCrab',
|
||||
@@ -562,6 +730,18 @@ function ActionGraphInner({
|
||||
setEdges(layoutedEdges)
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges, handleCrabClick])
|
||||
|
||||
// Re-organize: clear pinned positions and re-apply layout
|
||||
const handleReorganize = useCallback(() => {
|
||||
pinnedPositions.current.clear()
|
||||
setNodes((nds) => {
|
||||
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
|
||||
if (chaserNode) {
|
||||
return [...layoutedNodes, chaserNode]
|
||||
}
|
||||
return [...layoutedNodes]
|
||||
})
|
||||
}, [layoutedNodes, setNodes])
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -603,16 +783,56 @@ 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>
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
if (node.type === 'crab') return '#ef4444'
|
||||
if (node.type === 'chaserCrab') return '#ef4444'
|
||||
if (node.type === 'session') return '#98ffc8'
|
||||
if (node.type === 'exec') {
|
||||
const status = (node.data as unknown as MonitorExecProcess).status
|
||||
if (status === 'running') return '#00ffd5'
|
||||
if (status === 'failed') return '#ef4444'
|
||||
return '#98ffc8'
|
||||
}
|
||||
return '#52526e'
|
||||
}}
|
||||
maskColor="rgba(10, 10, 15, 0.8)"
|
||||
className="bg-shell-900! border-shell-700!"
|
||||
style={{ backgroundColor: '#0a0a0f' }}
|
||||
style={{ backgroundColor: '#0a0a0f', width: 100, height: 75 }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Bot,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
import type { MonitorAction } from '~/integrations/clawdbot'
|
||||
import type { MonitorAction } from '~/integrations/openclaw'
|
||||
|
||||
interface ActionNodeProps {
|
||||
data: MonitorAction
|
||||
@@ -39,12 +38,11 @@ const stateConfig: Record<
|
||||
}
|
||||
> = {
|
||||
start: {
|
||||
icon: Play,
|
||||
borderColor: 'border-neon-mint',
|
||||
bgColor: 'bg-neon-mint/10',
|
||||
iconColor: 'text-neon-mint',
|
||||
animate: false,
|
||||
label: 'Run Started',
|
||||
icon: Loader2,
|
||||
borderColor: 'border-neon-cyan',
|
||||
bgColor: 'bg-neon-cyan/10',
|
||||
iconColor: 'text-neon-cyan',
|
||||
animate: true,
|
||||
},
|
||||
streaming: {
|
||||
icon: Loader2,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { CheckCircle, Copy, Check, Loader2, Terminal, XCircle } from 'lucide-react'
|
||||
import type { MonitorExecProcess, MonitorExecOutputChunk } from '~/integrations/openclaw'
|
||||
|
||||
interface ExecNodeProps {
|
||||
data: MonitorExecProcess
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const secs = ms / 1000
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remainSecs = Math.floor(secs % 60)
|
||||
return `${mins}m ${remainSecs}s`
|
||||
}
|
||||
|
||||
function tailLinesFromChunks(chunks: MonitorExecOutputChunk[], maxLines: number): string {
|
||||
if (chunks.length === 0) return ''
|
||||
const merged = chunks.map((c) => c.text).join('')
|
||||
const lines = merged.split(/\r?\n/)
|
||||
return lines.slice(-maxLines).join('\n').trim()
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
MonitorExecProcess['status'],
|
||||
{
|
||||
icon: typeof Loader2
|
||||
borderColor: string
|
||||
badgeColor: string
|
||||
iconColor: string
|
||||
animate: boolean
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
running: {
|
||||
icon: Loader2,
|
||||
borderColor: 'border-neon-cyan',
|
||||
badgeColor: 'bg-neon-cyan/15 text-neon-cyan',
|
||||
iconColor: 'text-neon-cyan',
|
||||
animate: true,
|
||||
label: 'Running',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle,
|
||||
borderColor: 'border-neon-mint',
|
||||
badgeColor: 'bg-neon-mint/15 text-neon-mint',
|
||||
iconColor: 'text-neon-mint',
|
||||
animate: false,
|
||||
label: 'Completed',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
borderColor: 'border-crab-500',
|
||||
badgeColor: 'bg-crab-500/15 text-crab-300',
|
||||
iconColor: 'text-crab-400',
|
||||
animate: false,
|
||||
label: 'Failed',
|
||||
},
|
||||
}
|
||||
|
||||
function streamStyle(stream: MonitorExecOutputChunk['stream']): string {
|
||||
if (stream === 'stderr') {
|
||||
return 'text-crab-200 bg-crab-950/30 border-crab-900/60'
|
||||
}
|
||||
return 'text-neon-cyan/90 bg-shell-950 border-shell-800'
|
||||
}
|
||||
|
||||
export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const status = statusConfig[data.status]
|
||||
const StatusIcon = status.icon
|
||||
|
||||
const preview = useMemo(() => tailLinesFromChunks(data.outputs, 3), [data.outputs])
|
||||
const hasOutput = data.outputs.length > 0
|
||||
|
||||
const handleCopyPid = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(String(data.pid)).catch(() => {})
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}, [data.pid])
|
||||
|
||||
const displayDuration =
|
||||
data.durationMs != null
|
||||
? formatDuration(data.durationMs)
|
||||
: data.completedAt != null
|
||||
? formatDuration(Math.max(0, data.completedAt - data.startedAt))
|
||||
: null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className={`
|
||||
px-3 py-2.5 rounded-lg border-2 min-w-[220px] cursor-pointer
|
||||
bg-shell-900 ${status.borderColor}
|
||||
${selected ? 'ring-2 ring-white/30' : ''}
|
||||
${expanded ? 'max-w-[680px]' : 'max-w-[360px]'}
|
||||
transition-all duration-150 hover:bg-shell-800
|
||||
`}
|
||||
style={{
|
||||
boxShadow: selected
|
||||
? '0 0 15px rgba(239, 68, 68, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.35)',
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="bg-shell-600! w-2! h-2! border-shell-800!"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Terminal size={13} className="text-shell-400" />
|
||||
<span className="font-display text-xs font-medium text-gray-300 uppercase tracking-wide">
|
||||
Exec
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
ml-1 px-2 py-0.5 rounded-md border text-[10px] font-console truncate max-w-[220px]
|
||||
border-shell-700 ${status.badgeColor}
|
||||
`}
|
||||
title={data.command}
|
||||
>
|
||||
{data.command}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleCopyPid}
|
||||
className="p-1 rounded hover:bg-shell-700 transition-colors"
|
||||
title={`Copy PID: ${data.pid}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
<StatusIcon
|
||||
size={14}
|
||||
className={`${status.iconColor} ${status.animate ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="font-console text-xs text-shell-500 mb-1.5">
|
||||
<span className="text-crab-600">></span> {formatTime(data.lastActivityAt)}
|
||||
</div>
|
||||
|
||||
<div className="font-console text-xs text-shell-400 mb-1.5 flex gap-2 flex-wrap">
|
||||
<span>
|
||||
<span className="text-shell-500">pid:</span> {data.pid}
|
||||
</span>
|
||||
{data.exitCode != null && (
|
||||
<span>
|
||||
<span className="text-shell-500">exit:</span>{' '}
|
||||
<span className={data.exitCode === 0 ? 'text-neon-mint' : 'text-crab-300'}>
|
||||
{data.exitCode}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{displayDuration && (
|
||||
<span className="text-neon-cyan">{displayDuration}</span>
|
||||
)}
|
||||
{data.status === 'running' && (
|
||||
<span className="text-neon-peach">live</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.outputTruncated && (
|
||||
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
|
||||
output truncated
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOutput && !expanded && (
|
||||
<pre className="font-console text-[11px] text-shell-300 bg-shell-950 border border-shell-800 rounded p-2 overflow-hidden line-clamp-4 whitespace-pre-wrap">
|
||||
{preview || '(no output)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{hasOutput && expanded && (
|
||||
<div className="mt-1.5 border border-shell-800 rounded bg-shell-950/60 max-h-[320px] overflow-auto">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[10px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
|
||||
<span>{status.label}</span>
|
||||
<span>{data.outputs.length} chunks</span>
|
||||
</div>
|
||||
<div className="p-2 flex flex-col gap-1.5">
|
||||
{data.outputs.map((chunk) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
className={`border rounded px-2 py-1 ${streamStyle(chunk.stream)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1 text-[10px] font-console text-shell-500">
|
||||
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
|
||||
{chunk.stream}
|
||||
</span>
|
||||
<span>{formatTime(chunk.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="font-console text-[11px] whitespace-pre-wrap wrap-break-workds">
|
||||
{chunk.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="bg-shell-600! w-2! h-2! border-shell-800!"
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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";
|
||||
import type { MonitorSession } from "~/integrations/openclaw";
|
||||
|
||||
function isSubagent(session: MonitorSession): boolean {
|
||||
return Boolean(session.spawnedBy) || session.platform === "subagent" || session.key.includes("subagent");
|
||||
}
|
||||
|
||||
function XIcon({
|
||||
size = 14,
|
||||
@@ -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,9 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Users, User } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Users, User, Clock, Copy, Check } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
import type { MonitorSession } from '~/integrations/clawdbot'
|
||||
import type { MonitorSession } from '~/integrations/openclaw'
|
||||
|
||||
interface SessionNodeProps {
|
||||
data: MonitorSession
|
||||
@@ -15,13 +15,50 @@ const platformIcons: Record<string, string> = {
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
subagent: '🤖',
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (seconds < 60) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export const SessionNode = memo(function SessionNode({
|
||||
data,
|
||||
selected,
|
||||
}: SessionNodeProps) {
|
||||
const platformIcon = platformIcons[data.platform] ?? '📱'
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Detect if this is a subagent session by checking if platform is "subagent" or if key contains "subagent"
|
||||
const isSubagent = data.platform === 'subagent' || data.key.includes('subagent') || Boolean(data.spawnedBy)
|
||||
const platformIcon = isSubagent ? platformIcons.subagent : (platformIcons[data.platform] ?? '📱')
|
||||
const displayPlatform = isSubagent ? 'subagent' : data.platform
|
||||
|
||||
const relativeTime = (!data.lastActivityAt || data.lastActivityAt <= 0)
|
||||
? null
|
||||
: formatRelativeTime(data.lastActivityAt)
|
||||
|
||||
const handleCopyKey = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(data.key).catch(() => {})
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}, [data.key])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -32,22 +69,57 @@ export const SessionNode = memo(function SessionNode({
|
||||
bg-shell-900 text-white
|
||||
${selected ? 'border-crab-500' : 'border-shell-600'}
|
||||
${data.status === 'thinking' ? 'border-neon-peach' : ''}
|
||||
${isSubagent ? 'border-neon-cyan border-opacity-50' : ''}
|
||||
transition-all duration-150 hover:bg-shell-800
|
||||
`}
|
||||
style={{
|
||||
boxShadow: selected
|
||||
? '0 0 20px rgba(239, 68, 68, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
: isSubagent
|
||||
? '0 0 12px rgba(0, 255, 213, 0.2), 0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
|
||||
<Handle type="target" id="spawn-target" position={Position.Left} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">{platformIcon}</span>
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-wide text-gray-200">
|
||||
{data.platform}
|
||||
{displayPlatform}
|
||||
</span>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className="p-1 rounded hover:bg-shell-700 transition-colors"
|
||||
title={`Copy key: ${data.key}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -65,7 +137,15 @@ export const SessionNode = memo(function SessionNode({
|
||||
<span className="text-crab-600">></span> {data.agentId}
|
||||
</div>
|
||||
|
||||
{relativeTime && (
|
||||
<div className="flex items-center gap-1 mt-2 font-console text-[10px] text-shell-500">
|
||||
<Clock size={10} className="text-shell-600" />
|
||||
<span>{relativeTime}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
|
||||
<Handle type="source" id="spawn-source" position={Position.Right} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square } from 'lucide-react'
|
||||
import { Settings, X, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square, CloudDownload } from 'lucide-react'
|
||||
import { version } from '../../../package.json'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
connected: boolean
|
||||
@@ -137,31 +138,6 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historical mode toggle */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<History size={18} className="text-shell-500" />
|
||||
<span className="font-display text-sm font-medium text-gray-200 uppercase tracking-wide">
|
||||
Historical Mode
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-4">
|
||||
<span className="text-crab-600">></span> load past sessions on connect
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => onHistoricalModeChange(!historicalMode)}
|
||||
className={`w-full px-4 py-2 font-display text-xs uppercase tracking-wide rounded-lg transition-all ${
|
||||
historicalMode
|
||||
? 'bg-crab-600 text-white box-glow-red'
|
||||
: 'bg-shell-800 text-gray-400 hover:bg-shell-700'
|
||||
}`}
|
||||
>
|
||||
{historicalMode ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Debug mode toggle */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -243,6 +219,31 @@ export function SettingsPanel({
|
||||
<Trash2 size={12} />
|
||||
Clear Stored Data
|
||||
</button>
|
||||
|
||||
{/* Gateway sync sub-option */}
|
||||
<div className="mt-4 pt-4 border-t border-shell-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudDownload size={14} className="text-shell-500" />
|
||||
<span className="font-display text-xs text-gray-300 uppercase tracking-wide">
|
||||
Sync Gateway (24h)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onHistoricalModeChange(!historicalMode)}
|
||||
className={`px-3 py-1 font-display text-[10px] uppercase tracking-wide rounded transition-all ${
|
||||
historicalMode
|
||||
? 'bg-neon-cyan/20 text-neon-cyan'
|
||||
: 'bg-shell-800 text-gray-500 hover:bg-shell-700'
|
||||
}`}
|
||||
>
|
||||
{historicalMode ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-console text-[10px] text-shell-500">
|
||||
<span className="text-crab-600">></span> fetch 24h of sessions from gateway on refresh
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log collection */}
|
||||
@@ -322,7 +323,7 @@ export function SettingsPanel({
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
crabwalk v1.0.1
|
||||
crabwalk v{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ export { ActionGraph } from './ActionGraph'
|
||||
export { SessionList } from './SessionList'
|
||||
export { SessionNode } from './SessionNode'
|
||||
export { ActionNode } from './ActionNode'
|
||||
export { ExecNode } from './ExecNode'
|
||||
export { CrabNode } from './CrabNode'
|
||||
export { StatusIndicator } from './StatusIndicator'
|
||||
export { SettingsPanel } from './SettingsPanel'
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
// Format file size to human-readable format
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
interface FileTreeProps {
|
||||
entries: DirectoryEntry[]
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
level?: number
|
||||
}
|
||||
|
||||
interface FileTreeItemProps {
|
||||
entry: DirectoryEntry
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
level: number
|
||||
}
|
||||
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: FileTreeItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [children, setChildren] = useState<DirectoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Reset children when entry path changes (e.g., on refresh)
|
||||
useEffect(() => {
|
||||
setChildren([])
|
||||
setExpanded(false)
|
||||
}, [entry.path])
|
||||
const isSelected = selectedPath === entry.path
|
||||
const isDirectory = entry.type === 'directory'
|
||||
const paddingLeft = level * 16 + 8
|
||||
|
||||
const loadChildren = useCallback(async () => {
|
||||
if (!isDirectory || !onLoadDirectory) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const entries = await onLoadDirectory(entry.path)
|
||||
setChildren(entries)
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [entry.path, isDirectory, onLoadDirectory])
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isDirectory) {
|
||||
if (!expanded) {
|
||||
await loadChildren()
|
||||
setExpanded(true)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[expanded, loadChildren, isDirectory]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isDirectory) {
|
||||
if (!expanded) {
|
||||
await loadChildren()
|
||||
setExpanded(true)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
onSelect(entry.path, 'directory')
|
||||
} else {
|
||||
onSelect(entry.path, 'file')
|
||||
}
|
||||
}, [entry.path, entry.type, expanded, loadChildren, onSelect, isDirectory])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
onClick={handleClick}
|
||||
style={{ paddingLeft }}
|
||||
className={`w-full flex items-center gap-2 py-1.5 pr-3 text-left transition-all duration-150 rounded-md mx-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400 border-l-2 border-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100 border-l-2 border-transparent'
|
||||
}`}
|
||||
whileHover={{ x: 2 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{/* Expand/collapse chevron for directories */}
|
||||
{isDirectory ? (
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
className="p-0.5 hover:bg-shell-700 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<ChevronRight size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
) : expanded ? (
|
||||
<ChevronDown size={14} className="text-shell-500" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-shell-500" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-5" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{isDirectory ? (
|
||||
expanded ? (
|
||||
<FolderOpen size={16} className="text-neon-mint flex-shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-neon-mint flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<FileText
|
||||
size={16}
|
||||
className={`flex-shrink-0 ${
|
||||
entry.extension === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`font-console text-sm truncate flex-1 ${
|
||||
isSelected ? 'text-crab-400' : ''
|
||||
}`}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
{/* Metadata for files */}
|
||||
{!isDirectory && (
|
||||
<span className="font-console text-[10px] text-shell-600 flex-shrink-0">
|
||||
{entry.size !== undefined && formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Children */}
|
||||
<AnimatePresence>
|
||||
{expanded && isDirectory && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
children.map((childEntry) => (
|
||||
<FileTreeItem
|
||||
key={childEntry.path}
|
||||
entry={childEntry}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
level={level + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="py-1 px-4">
|
||||
<span className="font-console text-xs text-shell-500 italic">Empty folder</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, level = 0 }: FileTreeProps) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
level={level}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTree
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { FileText, AlertCircle } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
fileModified?: Date
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Format file size to human-readable format
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Format date to relative time
|
||||
function formatModifiedDate(date: Date | undefined): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 30) {
|
||||
return d.toLocaleDateString()
|
||||
} else if (days > 0) {
|
||||
return `${days}d ago`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ago`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ago`
|
||||
} else {
|
||||
return 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, fileName, fileSize, fileModified, error }: MarkdownViewerProps) {
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
|
||||
}, [fileName])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
|
||||
<AlertCircle size={32} className="text-crab-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content && !fileName) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
|
||||
<FileText size={32} className="text-shell-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">
|
||||
Select a file from the sidebar to view its contents
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-shell-800 bg-shell-900/50">
|
||||
<FileText size={18} className={isMarkdown ? 'text-crab-400' : 'text-shell-500'} />
|
||||
<h2 className="font-display text-sm text-gray-200">{fileName}</h2>
|
||||
{isMarkdown && (
|
||||
<span className="px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30">
|
||||
Markdown
|
||||
</span>
|
||||
)}
|
||||
{/* File metadata */}
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{fileSize !== undefined && (
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
{fileModified && (
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
{formatModifiedDate(fileModified)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 overflow-x-auto mb-4">
|
||||
<code className="text-sm font-mono text-gray-300">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="text-gray-300">{children}</li>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-shell-700 my-6" />,
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-4">{children}</table>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-shell-800">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownViewer
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FileTree } from './FileTree'
|
||||
export { MarkdownViewer } from './MarkdownViewer'
|
||||
@@ -1,289 +0,0 @@
|
||||
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
|
||||
import { parseSessionKey, type MonitorSession, type MonitorAction } from './protocol'
|
||||
|
||||
// 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>()
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
function isParentSession(key: string): boolean {
|
||||
return !isSubagentSession(key) && !key.includes('lifecycle')
|
||||
}
|
||||
|
||||
// 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 now = timestamp ?? Date.now()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
getKey: (item) => item.key,
|
||||
})
|
||||
)
|
||||
|
||||
export const actionsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorAction>({
|
||||
id: 'clawdbot-actions',
|
||||
getKey: (item) => item.id,
|
||||
})
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// Preserve existing spawnedBy - never overwrite once set
|
||||
const preservedSpawnedBy = existing.spawnedBy
|
||||
sessionsCollection.update(session.key, (draft) => {
|
||||
Object.assign(draft, session)
|
||||
if (preservedSpawnedBy) {
|
||||
draft.spawnedBy = preservedSpawnedBy
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// New session - infer spawnedBy for subagents if not provided
|
||||
let spawnedBy = session.spawnedBy
|
||||
if (!spawnedBy && isSubagentSession(session.key)) {
|
||||
spawnedBy = inferSpawnedBy(session.key, session.lastActivityAt)
|
||||
}
|
||||
sessionsCollection.insert({
|
||||
...session,
|
||||
spawnedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to add or update action
|
||||
// Aggregation strategy per run:
|
||||
// - start: one node per runId (appears immediately)
|
||||
// - streaming: aggregate all deltas into one node (content updates)
|
||||
// - complete: updates streaming node with final state & metadata
|
||||
// - tool_call/tool_result: separate nodes
|
||||
export function addAction(action: MonitorAction) {
|
||||
// Learn runId → sessionKey mapping from actions with real session keys
|
||||
if (action.sessionKey && !action.sessionKey.includes('lifecycle')) {
|
||||
runSessionMap.set(action.runId, action.sessionKey)
|
||||
|
||||
// Track activity on parent sessions for spawn inference
|
||||
if (isParentSession(action.sessionKey)) {
|
||||
trackParentActivity(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve sessionKey: use mapped value if action has lifecycle/invalid key
|
||||
let sessionKey = action.sessionKey
|
||||
if (!sessionKey || sessionKey === 'lifecycle') {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// No streaming action found, create as-is with complete state
|
||||
actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` })
|
||||
return
|
||||
}
|
||||
|
||||
// For tool_call/tool_result, add as separate nodes
|
||||
const existing = actionsCollection.state.get(action.id)
|
||||
if (!existing) {
|
||||
actionsCollection.insert({ ...action, sessionKey })
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update session status
|
||||
export function updateSessionStatus(
|
||||
key: string,
|
||||
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) => {
|
||||
draft.status = status
|
||||
draft.lastActivityAt = now
|
||||
})
|
||||
} else if (isSubagentSession(key)) {
|
||||
// New subagent session via status update - create with inferred parent
|
||||
const spawnedBy = inferSpawnedBy(key, now)
|
||||
const parsed = parseSessionKey(key)
|
||||
sessionsCollection.insert({
|
||||
key,
|
||||
agentId: parsed.agentId,
|
||||
platform: parsed.platform,
|
||||
recipient: parsed.recipient,
|
||||
isGroup: parsed.isGroup,
|
||||
lastActivityAt: now,
|
||||
status,
|
||||
spawnedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update partial session data
|
||||
export function updateSession(key: string, update: Partial<MonitorSession>) {
|
||||
const session = sessionsCollection.state.get(key)
|
||||
if (session) {
|
||||
sessionsCollection.update(key, (draft) => {
|
||||
Object.assign(draft, update)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
export function clearCollections() {
|
||||
runSessionMap.clear()
|
||||
parentSessionActivity.clear()
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
sessionsCollection.delete(session.key)
|
||||
}
|
||||
for (const action of actionsCollection.state.values()) {
|
||||
actionsCollection.delete(action.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate collections from server persistence
|
||||
export function hydrateFromServer(
|
||||
sessions: MonitorSession[],
|
||||
actions: MonitorAction[]
|
||||
) {
|
||||
// First clear existing data
|
||||
clearCollections()
|
||||
|
||||
// Replay actions first to build parent activity history
|
||||
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
for (const action of sortedActions) {
|
||||
// Track parent activity without inserting actions yet
|
||||
if (action.sessionKey && isParentSession(action.sessionKey)) {
|
||||
trackParentActivity(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
sessionsCollection.insert({ ...session, spawnedBy })
|
||||
} else {
|
||||
sessionsCollection.insert(session)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay actions through addAction for aggregation
|
||||
for (const action of sortedActions) {
|
||||
addAction(action)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export class ClawdbotClient {
|
||||
const timeout = setTimeout(() => {
|
||||
this._connecting = false
|
||||
this.ws?.close()
|
||||
reject(new Error('Connection timeout - is clawdbot gateway running?'))
|
||||
reject(new Error('Connection timeout - is openclaw gateway running?'))
|
||||
}, 10000)
|
||||
|
||||
try {
|
||||
@@ -77,7 +77,7 @@ export class ClawdbotClient {
|
||||
|
||||
this.handleMessage(msg, resolve, reject, timeout)
|
||||
} catch (e) {
|
||||
console.error('[clawdbot] Failed to parse message:', e)
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
|
||||
import {
|
||||
parseSessionKey,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
type MonitorExecEvent,
|
||||
type MonitorExecProcess,
|
||||
type MonitorExecOutputChunk,
|
||||
type MonitorExecProcessStatus,
|
||||
} from './protocol'
|
||||
|
||||
// Track runId → sessionKey mapping (learned from chat events)
|
||||
const runSessionMap = new Map<string, string>()
|
||||
|
||||
// Track recent parent session actions with precise timestamps
|
||||
// Stores the last few action timestamps per parent session
|
||||
const parentActionHistory = new Map<string, number[]>()
|
||||
const MAX_ACTION_HISTORY = 10
|
||||
|
||||
// Time window for spawn inference
|
||||
const SPAWN_INFERENCE_WINDOW_MS = 10000
|
||||
|
||||
function isSubagentSession(key: string): boolean {
|
||||
return key.includes('subagent')
|
||||
}
|
||||
|
||||
function isParentSession(key: string): boolean {
|
||||
return !isSubagentSession(key) && !key.includes('lifecycle')
|
||||
}
|
||||
|
||||
// Track an action on a parent session with its timestamp
|
||||
function trackParentAction(sessionKey: string, timestamp?: number) {
|
||||
if (!isParentSession(sessionKey)) return
|
||||
const ts = timestamp ?? Date.now()
|
||||
|
||||
let history = parentActionHistory.get(sessionKey)
|
||||
if (!history) {
|
||||
history = []
|
||||
parentActionHistory.set(sessionKey, history)
|
||||
}
|
||||
|
||||
history.push(ts)
|
||||
|
||||
// Keep only recent entries
|
||||
if (history.length > MAX_ACTION_HISTORY) {
|
||||
history.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// Infer which parent session spawned this subagent
|
||||
// Finds the parent with the most recent action before the subagent's timestamp
|
||||
function inferSpawnedBy(subagentKey: string, timestamp?: number): string | undefined {
|
||||
if (!isSubagentSession(subagentKey)) return undefined
|
||||
|
||||
const subagentTime = timestamp ?? Date.now()
|
||||
const cutoff = subagentTime - SPAWN_INFERENCE_WINDOW_MS
|
||||
|
||||
let bestParent: string | undefined
|
||||
let bestTime = 0
|
||||
|
||||
for (const [parentKey, history] of parentActionHistory) {
|
||||
// Find the most recent action from this parent that's before the subagent time
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const actionTime = history[i]!
|
||||
// Must be before subagent appeared and within window
|
||||
if (actionTime <= subagentTime && actionTime >= cutoff) {
|
||||
if (actionTime > bestTime) {
|
||||
bestTime = actionTime
|
||||
bestParent = parentKey
|
||||
}
|
||||
break // Found the most recent valid action for this parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestParent) {
|
||||
console.log(`[spawn] linked ${subagentKey} to ${bestParent} (action ${subagentTime - bestTime}ms before)`)
|
||||
} else {
|
||||
console.log(`[spawn] could not infer parent for ${subagentKey}`)
|
||||
}
|
||||
|
||||
return bestParent
|
||||
}
|
||||
|
||||
export const sessionsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorSession>({
|
||||
id: 'openclaw-sessions',
|
||||
getKey: (item) => item.key,
|
||||
})
|
||||
)
|
||||
|
||||
export const actionsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorAction>({
|
||||
id: 'openclaw-actions',
|
||||
getKey: (item) => item.id,
|
||||
})
|
||||
)
|
||||
|
||||
export const execsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorExecProcess>({
|
||||
id: 'openclaw-execs',
|
||||
getKey: (item) => item.id,
|
||||
})
|
||||
)
|
||||
|
||||
const EXEC_PLACEHOLDER_COMMAND = 'Exec'
|
||||
const MAX_EXEC_OUTPUT_CHUNKS = 200
|
||||
const MAX_EXEC_OUTPUT_CHARS = 50000
|
||||
const MAX_EXEC_CHUNK_CHARS = 4000
|
||||
|
||||
function resolveSessionKey(event: MonitorExecEvent): string | undefined {
|
||||
return event.sessionKey || runSessionMap.get(event.runId) || event.sessionId
|
||||
}
|
||||
|
||||
function backfillExecSessionKey(runId: string, sessionKey: string) {
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
if (exec.runId !== runId) continue
|
||||
if (exec.sessionKey && exec.sessionKey !== exec.sessionId) continue
|
||||
execsCollection.update(exec.id, (draft) => {
|
||||
draft.sessionKey = sessionKey
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function mapExecStatus(exitCode?: number, status?: string): MonitorExecProcessStatus {
|
||||
if (typeof exitCode === 'number' && exitCode !== 0) return 'failed'
|
||||
if (typeof status === 'string') {
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized.includes('fail') || normalized.includes('error')) {
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function capExecOutputs(outputs: MonitorExecOutputChunk[]): {
|
||||
outputs: MonitorExecOutputChunk[]
|
||||
truncated: boolean
|
||||
} {
|
||||
let truncated = false
|
||||
const normalized: MonitorExecOutputChunk[] = outputs.map((chunk) => {
|
||||
if (chunk.text.length <= MAX_EXEC_CHUNK_CHARS) {
|
||||
return chunk
|
||||
}
|
||||
truncated = true
|
||||
return {
|
||||
...chunk,
|
||||
text: chunk.text.slice(0, MAX_EXEC_CHUNK_CHARS) + '\n...[truncated]',
|
||||
}
|
||||
})
|
||||
|
||||
let capped = normalized
|
||||
if (capped.length > MAX_EXEC_OUTPUT_CHUNKS) {
|
||||
truncated = true
|
||||
capped = capped.slice(-MAX_EXEC_OUTPUT_CHUNKS)
|
||||
}
|
||||
|
||||
const totalChars = capped.reduce((sum, chunk) => sum + chunk.text.length, 0)
|
||||
if (totalChars > MAX_EXEC_OUTPUT_CHARS) {
|
||||
truncated = true
|
||||
let dropped = 0
|
||||
let startIdx = 0
|
||||
for (let i = 0; i < capped.length; i++) {
|
||||
if (totalChars - dropped <= MAX_EXEC_OUTPUT_CHARS) break
|
||||
dropped += capped[i]!.text.length
|
||||
startIdx = i + 1
|
||||
}
|
||||
capped = capped.slice(startIdx)
|
||||
}
|
||||
|
||||
return { outputs: capped, truncated }
|
||||
}
|
||||
|
||||
function createPlaceholderExec(event: MonitorExecEvent, sessionKey?: string): MonitorExecProcess {
|
||||
const startedAt = event.startedAt ?? event.timestamp
|
||||
return {
|
||||
id: event.execId,
|
||||
runId: event.runId,
|
||||
pid: event.pid,
|
||||
command: event.command || EXEC_PLACEHOLDER_COMMAND,
|
||||
sessionId: event.sessionId,
|
||||
sessionKey,
|
||||
status: event.eventType === 'completed'
|
||||
? mapExecStatus(event.exitCode, event.status)
|
||||
: 'running',
|
||||
startedAt,
|
||||
timestamp: startedAt,
|
||||
outputs: [],
|
||||
lastActivityAt: event.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update or insert session
|
||||
export function upsertSession(session: MonitorSession) {
|
||||
const existing = sessionsCollection.state.get(session.key)
|
||||
|
||||
if (existing) {
|
||||
// Preserve existing spawnedBy - never overwrite once set
|
||||
const preservedSpawnedBy = existing.spawnedBy
|
||||
sessionsCollection.update(session.key, (draft) => {
|
||||
Object.assign(draft, session)
|
||||
if (preservedSpawnedBy) {
|
||||
draft.spawnedBy = preservedSpawnedBy
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// New session - infer spawnedBy for subagents if not provided
|
||||
let spawnedBy = session.spawnedBy
|
||||
if (!spawnedBy && isSubagentSession(session.key)) {
|
||||
spawnedBy = inferSpawnedBy(session.key, session.lastActivityAt)
|
||||
}
|
||||
sessionsCollection.insert({
|
||||
...session,
|
||||
spawnedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to add or update action
|
||||
// Unified node lifecycle per runId:
|
||||
// - start/streaming/complete/error/aborted all update the same node
|
||||
// - Node type reflects current state in the lifecycle
|
||||
// - tool_call/tool_result: separate nodes
|
||||
export function addAction(action: MonitorAction) {
|
||||
// Learn runId → sessionKey mapping from actions with real session keys
|
||||
if (action.sessionKey && !action.sessionKey.includes('lifecycle')) {
|
||||
const previous = runSessionMap.get(action.runId)
|
||||
runSessionMap.set(action.runId, action.sessionKey)
|
||||
if (previous !== action.sessionKey) {
|
||||
backfillExecSessionKey(action.runId, action.sessionKey)
|
||||
}
|
||||
|
||||
// Track parent session actions for spawn inference
|
||||
if (isParentSession(action.sessionKey)) {
|
||||
trackParentAction(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve sessionKey: use mapped value if action has lifecycle/invalid key
|
||||
let sessionKey = action.sessionKey
|
||||
if (!sessionKey || sessionKey === 'lifecycle') {
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Create new action node
|
||||
actionsCollection.insert({
|
||||
...action,
|
||||
id: actionNodeId,
|
||||
sessionKey,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For tool_call/tool_result, add as separate nodes
|
||||
const existing = actionsCollection.state.get(action.id)
|
||||
if (!existing) {
|
||||
actionsCollection.insert({ ...action, sessionKey })
|
||||
}
|
||||
}
|
||||
|
||||
export function addExecEvent(event: MonitorExecEvent) {
|
||||
const sessionKey = resolveSessionKey(event)
|
||||
const existing = execsCollection.state.get(event.execId)
|
||||
|
||||
if (event.eventType === 'started') {
|
||||
if (existing) {
|
||||
execsCollection.update(event.execId, (draft) => {
|
||||
draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND
|
||||
draft.sessionId = event.sessionId || draft.sessionId
|
||||
draft.sessionKey = sessionKey || draft.sessionKey
|
||||
draft.status = 'running'
|
||||
draft.startedAt = event.startedAt ?? draft.startedAt ?? event.timestamp
|
||||
draft.timestamp = draft.startedAt
|
||||
draft.lastActivityAt = event.timestamp
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
execsCollection.insert({
|
||||
...createPlaceholderExec(event, sessionKey),
|
||||
command: event.command || EXEC_PLACEHOLDER_COMMAND,
|
||||
startedAt: event.startedAt ?? event.timestamp,
|
||||
timestamp: event.startedAt ?? event.timestamp,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.eventType === 'output') {
|
||||
const stream = event.stream || 'stdout'
|
||||
const text = event.output ?? ''
|
||||
const chunk: MonitorExecOutputChunk = {
|
||||
id: event.id,
|
||||
stream,
|
||||
text,
|
||||
timestamp: event.timestamp,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
execsCollection.update(event.execId, (draft) => {
|
||||
draft.sessionId = event.sessionId || draft.sessionId
|
||||
draft.sessionKey = sessionKey || draft.sessionKey
|
||||
draft.lastActivityAt = event.timestamp
|
||||
if (text) {
|
||||
const capped = capExecOutputs([...draft.outputs, chunk])
|
||||
draft.outputs = capped.outputs
|
||||
draft.outputTruncated = draft.outputTruncated || capped.truncated
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const placeholder = createPlaceholderExec(event, sessionKey)
|
||||
if (text) {
|
||||
const capped = capExecOutputs([chunk])
|
||||
placeholder.outputs = capped.outputs
|
||||
placeholder.outputTruncated = capped.truncated
|
||||
}
|
||||
execsCollection.insert(placeholder)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.eventType === 'completed') {
|
||||
const completedStatus = mapExecStatus(event.exitCode, event.status)
|
||||
if (existing) {
|
||||
execsCollection.update(event.execId, (draft) => {
|
||||
draft.sessionId = event.sessionId || draft.sessionId
|
||||
draft.sessionKey = sessionKey || draft.sessionKey
|
||||
draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND
|
||||
draft.exitCode = event.exitCode ?? draft.exitCode
|
||||
draft.durationMs = event.durationMs ?? draft.durationMs
|
||||
const completedAt = draft.durationMs != null
|
||||
? draft.startedAt + draft.durationMs
|
||||
: event.timestamp
|
||||
draft.completedAt = completedAt
|
||||
draft.status = completedStatus
|
||||
draft.lastActivityAt = event.timestamp
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const placeholder = createPlaceholderExec(event, sessionKey)
|
||||
placeholder.command = event.command || placeholder.command
|
||||
placeholder.exitCode = event.exitCode
|
||||
placeholder.durationMs = event.durationMs
|
||||
placeholder.completedAt = placeholder.durationMs != null
|
||||
? placeholder.startedAt + placeholder.durationMs
|
||||
: event.timestamp
|
||||
placeholder.status = completedStatus
|
||||
execsCollection.insert(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update session status
|
||||
export function updateSessionStatus(
|
||||
key: string,
|
||||
status: MonitorSession['status']
|
||||
) {
|
||||
const now = Date.now()
|
||||
const session = sessionsCollection.state.get(key)
|
||||
if (session) {
|
||||
sessionsCollection.update(key, (draft) => {
|
||||
draft.status = status
|
||||
draft.lastActivityAt = now
|
||||
})
|
||||
} else if (isSubagentSession(key)) {
|
||||
// New subagent session via status update - create with inferred parent
|
||||
const spawnedBy = inferSpawnedBy(key, now)
|
||||
const parsed = parseSessionKey(key)
|
||||
sessionsCollection.insert({
|
||||
key,
|
||||
agentId: parsed.agentId,
|
||||
platform: parsed.platform,
|
||||
recipient: parsed.recipient,
|
||||
isGroup: parsed.isGroup,
|
||||
lastActivityAt: now,
|
||||
status,
|
||||
spawnedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update partial session data
|
||||
export function updateSession(key: string, update: Partial<MonitorSession>) {
|
||||
const session = sessionsCollection.state.get(key)
|
||||
if (session) {
|
||||
sessionsCollection.update(key, (draft) => {
|
||||
Object.assign(draft, update)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
export function clearCollections() {
|
||||
runSessionMap.clear()
|
||||
parentActionHistory.clear()
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
sessionsCollection.delete(session.key)
|
||||
}
|
||||
for (const action of actionsCollection.state.values()) {
|
||||
actionsCollection.delete(action.id)
|
||||
}
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
execsCollection.delete(exec.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Get count of completed/failed execs (for UI badge)
|
||||
export function getCompletedExecCount(): number {
|
||||
let count = 0
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
if (exec.status === 'completed' || exec.status === 'failed') {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Clear completed and failed execs from state
|
||||
// Returns number of items cleared
|
||||
export function clearCompletedExecs(): number {
|
||||
const toDelete: string[] = []
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
if (exec.status === 'completed' || exec.status === 'failed') {
|
||||
toDelete.push(exec.id)
|
||||
}
|
||||
}
|
||||
for (const id of toDelete) {
|
||||
execsCollection.delete(id)
|
||||
}
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
// Clear inactive sessions (idle sessions with no activity for thresholdMs)
|
||||
// Returns number of sessions cleared
|
||||
export function clearInactiveSessions(thresholdMs: number): number {
|
||||
const now = Date.now()
|
||||
const toDelete: string[] = []
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
// Only clear idle sessions - preserve thinking/active ones
|
||||
if (session.status !== 'idle') continue
|
||||
const inactiveTime = now - session.lastActivityAt
|
||||
if (inactiveTime >= thresholdMs) {
|
||||
toDelete.push(session.key)
|
||||
}
|
||||
}
|
||||
for (const key of toDelete) {
|
||||
sessionsCollection.delete(key)
|
||||
}
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
// Hydrate collections from server persistence
|
||||
export function hydrateFromServer(
|
||||
sessions: MonitorSession[],
|
||||
actions: MonitorAction[],
|
||||
execEvents: MonitorExecEvent[] = []
|
||||
) {
|
||||
// First clear existing data
|
||||
clearCollections()
|
||||
|
||||
// Sort actions by timestamp for replay
|
||||
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// First pass: build parent action history for spawn inference
|
||||
for (const action of sortedActions) {
|
||||
if (action.sessionKey && isParentSession(action.sessionKey)) {
|
||||
trackParentAction(action.sessionKey, action.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Also track parent sessions by their lastActivityAt
|
||||
for (const session of sessions) {
|
||||
if (isParentSession(session.key)) {
|
||||
trackParentAction(session.key, session.lastActivityAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert all sessions - subagents will get inferred spawnedBy from Task tool calls
|
||||
for (const session of sessions) {
|
||||
if (isSubagentSession(session.key)) {
|
||||
const spawnedBy = session.spawnedBy || inferSpawnedBy(session.key, session.lastActivityAt)
|
||||
sessionsCollection.insert({ ...session, spawnedBy })
|
||||
} else {
|
||||
sessionsCollection.insert(session)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay actions through addAction for aggregation
|
||||
for (const action of sortedActions) {
|
||||
addAction(action)
|
||||
}
|
||||
|
||||
// Replay exec events after actions to maximize sessionKey resolution
|
||||
const sortedExecEvents = [...execEvents].sort((a, b) => a.timestamp - b.timestamp)
|
||||
for (const event of sortedExecEvents) {
|
||||
addExecEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import type {
|
||||
EventFrame,
|
||||
ChatEvent,
|
||||
AgentEvent,
|
||||
ExecStartedEvent,
|
||||
ExecOutputEvent,
|
||||
ExecCompletedEvent,
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecEvent,
|
||||
SessionInfo,
|
||||
} from './protocol'
|
||||
import { parseSessionKey } from './protocol'
|
||||
@@ -109,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') {
|
||||
@@ -124,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 || '')
|
||||
}
|
||||
@@ -148,7 +153,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
|
||||
export function parseEventFrame(
|
||||
frame: EventFrame
|
||||
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
|
||||
): {
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
execEvent?: MonitorExecEvent
|
||||
} | null {
|
||||
// Skip system events
|
||||
if (frame.event === 'health' || frame.event === 'tick') {
|
||||
return null
|
||||
@@ -169,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),
|
||||
@@ -186,8 +190,123 @@ export function parseEventFrame(
|
||||
}
|
||||
}
|
||||
|
||||
// Process assistant stream for streaming content
|
||||
// Assistant events have { text: "cumulative", delta: "incremental" } structure
|
||||
if (agentEvent.stream === 'assistant' && typeof agentEvent.data?.text === 'string') {
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: 'thinking',
|
||||
lastActivityAt: Date.now(),
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool events (tool_use, tool_result)
|
||||
if (agentEvent.data?.type === 'tool_use' || agentEvent.data?.type === 'tool_result') {
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: 'thinking',
|
||||
lastActivityAt: Date.now(),
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (frame.event === 'exec.started' && frame.payload) {
|
||||
const exec = frame.payload as ExecStartedEvent
|
||||
const execId = `exec-${exec.runId}-${exec.pid}`
|
||||
const timestamp = Date.now()
|
||||
const id = frame.seq != null
|
||||
? `${execId}-started-${frame.seq}`
|
||||
: `${execId}-started-${timestamp}`
|
||||
|
||||
return {
|
||||
execEvent: {
|
||||
id,
|
||||
execId,
|
||||
runId: exec.runId,
|
||||
pid: exec.pid,
|
||||
sessionId: exec.sessionId,
|
||||
eventType: 'started',
|
||||
command: exec.command,
|
||||
startedAt: exec.startedAt,
|
||||
timestamp,
|
||||
},
|
||||
session: exec.sessionId
|
||||
? {
|
||||
key: exec.sessionId,
|
||||
status: 'thinking',
|
||||
lastActivityAt: timestamp,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.event === 'exec.output' && frame.payload) {
|
||||
const exec = frame.payload as ExecOutputEvent
|
||||
const execId = `exec-${exec.runId}-${exec.pid}`
|
||||
const timestamp = Date.now()
|
||||
const id = frame.seq != null
|
||||
? `${execId}-output-${frame.seq}`
|
||||
: `${execId}-output-${timestamp}`
|
||||
|
||||
return {
|
||||
execEvent: {
|
||||
id,
|
||||
execId,
|
||||
runId: exec.runId,
|
||||
pid: exec.pid,
|
||||
sessionId: exec.sessionId,
|
||||
eventType: 'output',
|
||||
stream: exec.stream,
|
||||
output: exec.output,
|
||||
timestamp,
|
||||
},
|
||||
session: exec.sessionId
|
||||
? {
|
||||
key: exec.sessionId,
|
||||
lastActivityAt: timestamp,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.event === 'exec.completed' && frame.payload) {
|
||||
const exec = frame.payload as ExecCompletedEvent
|
||||
const execId = `exec-${exec.runId}-${exec.pid}`
|
||||
const timestamp = Date.now()
|
||||
const id = frame.seq != null
|
||||
? `${execId}-completed-${frame.seq}`
|
||||
: `${execId}-completed-${timestamp}`
|
||||
|
||||
return {
|
||||
execEvent: {
|
||||
id,
|
||||
execId,
|
||||
runId: exec.runId,
|
||||
pid: exec.pid,
|
||||
sessionId: exec.sessionId,
|
||||
eventType: 'completed',
|
||||
durationMs: exec.durationMs,
|
||||
exitCode: exec.exitCode,
|
||||
status: exec.status,
|
||||
timestamp,
|
||||
},
|
||||
session: exec.sessionId
|
||||
? {
|
||||
key: exec.sessionId,
|
||||
status: 'active',
|
||||
lastActivityAt: timestamp,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
+63
-2
@@ -1,12 +1,14 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { MonitorSession, MonitorAction } from './protocol'
|
||||
import type { MonitorSession, MonitorAction, MonitorExecEvent } from './protocol'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json')
|
||||
const ACTIONS_FILE = path.join(DATA_DIR, 'actions.jsonl')
|
||||
const EXEC_EVENTS_FILE = path.join(DATA_DIR, 'exec-events.jsonl')
|
||||
const STATE_FILE = path.join(DATA_DIR, 'state.json')
|
||||
const MAX_ACTIONS = 10000
|
||||
const MAX_EXEC_EVENTS = 20000
|
||||
|
||||
interface PersistenceState {
|
||||
enabled: boolean
|
||||
@@ -16,6 +18,7 @@ interface PersistenceState {
|
||||
class PersistenceService {
|
||||
private sessions: Map<string, MonitorSession> = new Map()
|
||||
private actions: MonitorAction[] = []
|
||||
private execEvents: MonitorExecEvent[] = []
|
||||
private enabled = false
|
||||
private startedAt: number | null = null
|
||||
|
||||
@@ -90,6 +93,28 @@ class PersistenceService {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Load exec events (JSONL)
|
||||
try {
|
||||
if (fs.existsSync(EXEC_EVENTS_FILE)) {
|
||||
const content = fs.readFileSync(EXEC_EVENTS_FILE, 'utf-8')
|
||||
const lines = content.trim().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line) as MonitorExecEvent
|
||||
this.execEvents.push(event)
|
||||
} catch {
|
||||
// skip bad lines
|
||||
}
|
||||
}
|
||||
if (this.execEvents.length > MAX_EXEC_EVENTS) {
|
||||
this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS)
|
||||
this.saveExecEvents()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveSessions() {
|
||||
@@ -102,10 +127,19 @@ class PersistenceService {
|
||||
fs.writeFileSync(ACTIONS_FILE, content)
|
||||
}
|
||||
|
||||
private saveExecEvents() {
|
||||
const content = this.execEvents.map((e) => JSON.stringify(e)).join('\n')
|
||||
fs.writeFileSync(EXEC_EVENTS_FILE, content)
|
||||
}
|
||||
|
||||
private appendAction(action: MonitorAction) {
|
||||
fs.appendFileSync(ACTIONS_FILE, JSON.stringify(action) + '\n')
|
||||
}
|
||||
|
||||
private appendExecEvent(event: MonitorExecEvent) {
|
||||
fs.appendFileSync(EXEC_EVENTS_FILE, JSON.stringify(event) + '\n')
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return this.enabled
|
||||
}
|
||||
@@ -131,12 +165,14 @@ class PersistenceService {
|
||||
startedAt: number | null
|
||||
sessionCount: number
|
||||
actionCount: number
|
||||
execEventCount: number
|
||||
} {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
startedAt: this.startedAt,
|
||||
sessionCount: this.sessions.size,
|
||||
actionCount: this.actions.length,
|
||||
execEventCount: this.execEvents.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,19 +204,44 @@ class PersistenceService {
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(): { sessions: MonitorSession[]; actions: MonitorAction[] } {
|
||||
addExecEvent(event: MonitorExecEvent) {
|
||||
if (!this.enabled) return
|
||||
|
||||
const existingIdx = this.execEvents.findIndex((e) => e.id === event.id)
|
||||
if (existingIdx >= 0) {
|
||||
this.execEvents[existingIdx] = event
|
||||
this.saveExecEvents()
|
||||
} else {
|
||||
this.execEvents.push(event)
|
||||
this.appendExecEvent(event)
|
||||
|
||||
if (this.execEvents.length > MAX_EXEC_EVENTS) {
|
||||
this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS)
|
||||
this.saveExecEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(): {
|
||||
sessions: MonitorSession[]
|
||||
actions: MonitorAction[]
|
||||
execEvents: MonitorExecEvent[]
|
||||
} {
|
||||
return {
|
||||
sessions: Array.from(this.sessions.values()),
|
||||
actions: [...this.actions],
|
||||
execEvents: [...this.execEvents],
|
||||
}
|
||||
}
|
||||
|
||||
clear(): { cleared: boolean } {
|
||||
this.sessions.clear()
|
||||
this.actions = []
|
||||
this.execEvents = []
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) fs.unlinkSync(SESSIONS_FILE)
|
||||
if (fs.existsSync(ACTIONS_FILE)) fs.unlinkSync(ACTIONS_FILE)
|
||||
if (fs.existsSync(EXEC_EVENTS_FILE)) fs.unlinkSync(EXEC_EVENTS_FILE)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -85,6 +85,32 @@ export interface AgentEvent {
|
||||
sessionKey?: string
|
||||
}
|
||||
|
||||
// Exec events
|
||||
export interface ExecStartedEvent {
|
||||
pid: number
|
||||
command: string
|
||||
sessionId: string
|
||||
runId: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface ExecOutputEvent {
|
||||
pid: number
|
||||
runId: string
|
||||
sessionId?: string
|
||||
stream: 'stdout' | 'stderr' | string
|
||||
output: string
|
||||
}
|
||||
|
||||
export interface ExecCompletedEvent {
|
||||
pid: number
|
||||
runId: string
|
||||
sessionId?: string
|
||||
exitCode: number
|
||||
durationMs: number
|
||||
status: string
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export interface SessionsListParams {
|
||||
limit?: number
|
||||
@@ -137,6 +163,53 @@ export interface MonitorAction {
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
export type MonitorExecEventType = 'started' | 'output' | 'completed'
|
||||
|
||||
export interface MonitorExecEvent {
|
||||
id: string
|
||||
execId: string
|
||||
runId: string
|
||||
pid: number
|
||||
sessionId?: string
|
||||
sessionKey?: string
|
||||
eventType: MonitorExecEventType
|
||||
command?: string
|
||||
stream?: 'stdout' | 'stderr' | string
|
||||
output?: string
|
||||
startedAt?: number
|
||||
durationMs?: number
|
||||
exitCode?: number
|
||||
status?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type MonitorExecProcessStatus = 'running' | 'completed' | 'failed'
|
||||
|
||||
export interface MonitorExecOutputChunk {
|
||||
id: string
|
||||
stream: 'stdout' | 'stderr' | string
|
||||
text: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface MonitorExecProcess {
|
||||
id: string
|
||||
runId: string
|
||||
pid: number
|
||||
command: string
|
||||
sessionId?: string
|
||||
sessionKey?: string
|
||||
status: MonitorExecProcessStatus
|
||||
startedAt: number
|
||||
completedAt?: number
|
||||
durationMs?: number
|
||||
exitCode?: number
|
||||
outputs: MonitorExecOutputChunk[]
|
||||
outputTruncated?: boolean
|
||||
timestamp: number
|
||||
lastActivityAt: number
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export function parseSessionKey(key: string): {
|
||||
agentId: string
|
||||
@@ -2,14 +2,24 @@ import { initTRPC } from '@trpc/server'
|
||||
import { observable } from '@trpc/server/observable'
|
||||
import superjson from 'superjson'
|
||||
import { z } from 'zod'
|
||||
import { getClawdbotClient } from '~/integrations/clawdbot/client'
|
||||
import { getPersistenceService } from '~/integrations/clawdbot/persistence'
|
||||
import { getClawdbotClient } from '~/integrations/openclaw/client'
|
||||
import { getPersistenceService } from '~/integrations/openclaw/persistence'
|
||||
import {
|
||||
parseEventFrame,
|
||||
sessionInfoToMonitor,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
} from '~/integrations/clawdbot'
|
||||
type MonitorExecEvent,
|
||||
} from '~/integrations/openclaw'
|
||||
import {
|
||||
listDirectory,
|
||||
readFile,
|
||||
pathExists,
|
||||
getDefaultWorkspacePath,
|
||||
expandTilde,
|
||||
type DirectoryEntry,
|
||||
type FileContent,
|
||||
} from '~/lib/workspace-fs'
|
||||
|
||||
// Server-side debug mode state
|
||||
let debugMode = false
|
||||
@@ -26,7 +36,7 @@ export const router = t.router
|
||||
export const publicProcedure = t.procedure
|
||||
|
||||
// Clawdbot router
|
||||
const clawdbotRouter = router({
|
||||
const openclawRouter = router({
|
||||
connect: publicProcedure.mutation(async () => {
|
||||
const client = getClawdbotClient()
|
||||
if (client.connected) {
|
||||
@@ -63,7 +73,7 @@ const clawdbotRouter = router({
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(({ input }) => {
|
||||
debugMode = input.enabled
|
||||
console.log(`[clawdbot] debug mode ${debugMode ? 'enabled' : 'disabled'}`)
|
||||
console.log(`[openclaw] debug mode ${debugMode ? 'enabled' : 'disabled'}`)
|
||||
return { debugMode }
|
||||
}),
|
||||
|
||||
@@ -77,9 +87,9 @@ const clawdbotRouter = router({
|
||||
.mutation(({ input }) => {
|
||||
collectLogs = input.enabled
|
||||
if (input.enabled) {
|
||||
console.log(`[clawdbot] log collection started`)
|
||||
console.log(`[openclaw] log collection started`)
|
||||
} else {
|
||||
console.log(`[clawdbot] log collection stopped, ${collectedEvents.length} events collected`)
|
||||
console.log(`[openclaw] log collection stopped, ${collectedEvents.length} events collected`)
|
||||
}
|
||||
return { collectLogs, eventCount: collectedEvents.length }
|
||||
}),
|
||||
@@ -99,7 +109,7 @@ const clawdbotRouter = router({
|
||||
clearLogs: publicProcedure.mutation(() => {
|
||||
const count = collectedEvents.length
|
||||
collectedEvents.length = 0
|
||||
console.log(`[clawdbot] cleared ${count} collected events`)
|
||||
console.log(`[openclaw] cleared ${count} collected events`)
|
||||
return { cleared: count }
|
||||
}),
|
||||
|
||||
@@ -137,9 +147,10 @@ const clawdbotRouter = router({
|
||||
|
||||
events: publicProcedure.subscription(() => {
|
||||
return observable<{
|
||||
type: 'session' | 'action'
|
||||
type: 'session' | 'action' | 'exec'
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
execEvent?: MonitorExecEvent
|
||||
}>((emit) => {
|
||||
const client = getClawdbotClient()
|
||||
const persistence = getPersistenceService()
|
||||
@@ -163,6 +174,9 @@ const clawdbotRouter = router({
|
||||
if (debugMode && parsed.action) {
|
||||
console.log('[DEBUG] Parsed action:', parsed.action.type, parsed.action.eventType, 'sessionKey:', parsed.action.sessionKey)
|
||||
}
|
||||
if (debugMode && parsed.execEvent) {
|
||||
console.log('[DEBUG] Parsed exec:', parsed.execEvent.eventType, 'runId:', parsed.execEvent.runId, 'pid:', parsed.execEvent.pid)
|
||||
}
|
||||
if (parsed.session) {
|
||||
emit.next({ type: 'session', session: parsed.session })
|
||||
}
|
||||
@@ -171,6 +185,10 @@ const clawdbotRouter = router({
|
||||
persistence.addAction(parsed.action)
|
||||
emit.next({ type: 'action', action: parsed.action })
|
||||
}
|
||||
if (parsed.execEvent) {
|
||||
persistence.addExecEvent(parsed.execEvent)
|
||||
emit.next({ type: 'exec', execEvent: parsed.execEvent })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -207,6 +225,69 @@ const clawdbotRouter = router({
|
||||
}),
|
||||
})
|
||||
|
||||
// Workspace router for file system operations
|
||||
const workspaceRouter = router({
|
||||
// Validate workspace path exists
|
||||
validatePath: publicProcedure
|
||||
.input(z.object({ path: z.string() }))
|
||||
.query(async ({ input }): Promise<{ valid: boolean; error?: string; expandedPath?: string }> => {
|
||||
try {
|
||||
const expandedPath = expandTilde(input.path)
|
||||
const exists = await pathExists(expandedPath)
|
||||
if (!exists) {
|
||||
return { valid: false, error: 'Path does not exist' }
|
||||
}
|
||||
return { valid: true, expandedPath }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Get default workspace path
|
||||
getDefaultPath: publicProcedure.query((): { path: string } => {
|
||||
return { path: getDefaultWorkspacePath() }
|
||||
}),
|
||||
|
||||
// List directory contents
|
||||
listDirectory: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
|
||||
.query(async ({ input }): Promise<{ entries: DirectoryEntry[]; error?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
const expandedPath = expandTilde(input.path)
|
||||
const entries = await listDirectory(expandedRoot, expandedPath)
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return {
|
||||
entries: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to list directory',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Read file contents
|
||||
readFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
|
||||
.query(async ({ input }): Promise<FileContent & { error?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
const expandedPath = expandTilde(input.path)
|
||||
const result = await readFile(expandedRoot, expandedPath)
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
content: '',
|
||||
path: input.path,
|
||||
name: '',
|
||||
error: error instanceof Error ? error.message : 'Failed to read file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const appRouter = router({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ name: z.string().optional() }))
|
||||
@@ -222,7 +303,8 @@ export const appRouter = router({
|
||||
]
|
||||
}),
|
||||
|
||||
clawdbot: clawdbotRouter,
|
||||
openclaw: openclawRouter,
|
||||
workspace: workspaceRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
+364
-38
@@ -1,5 +1,14 @@
|
||||
import dagre from 'dagre'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecProcess,
|
||||
} from '~/integrations/openclaw'
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
function nodeData<T>(data: T): Record<string, unknown> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction?: 'TB' | 'LR' | 'BT' | 'RL'
|
||||
@@ -9,60 +18,377 @@ export interface LayoutOptions {
|
||||
nodeSep?: number
|
||||
}
|
||||
|
||||
// Node sizing configuration - sized generously for layout calculations
|
||||
const NODE_DIMENSIONS = {
|
||||
session: { width: 280, height: 140 }, // Wider for session cards
|
||||
exec: { width: 300, height: 120 }, // Exec processes need room
|
||||
action: { width: 220, height: 100 }, // Chat events with padding
|
||||
crab: { width: 64, height: 64 },
|
||||
}
|
||||
|
||||
// Layout constants - generous spacing for clarity
|
||||
const COLUMN_GAP = 400 // Horizontal gap between session columns
|
||||
const ROW_GAP = 80 // Vertical gap between items in a column
|
||||
const SPAWN_OFFSET = 60 // Extra Y offset when spawning to right
|
||||
const CRAB_OFFSET = { x: -120, y: -100 }
|
||||
const ROOT_START_Y = 200 // Vertical offset from crab to first root session
|
||||
const MIN_SESSION_GAP = 120 // Minimum vertical gap between sessions in same column
|
||||
const ROOT_HORIZONTAL_GAP = 0 // Gap between root sessions in horizontal mode
|
||||
|
||||
// Cache spawn Y positions so they don't change as parent actions accumulate
|
||||
// Key: session key, Value: calculated spawn Y offset
|
||||
const spawnYCache = new Map<string, number>()
|
||||
|
||||
interface SessionColumn {
|
||||
sessionKey: string
|
||||
columnIndex: number
|
||||
rootIndex: number // Which root tree this session belongs to (for horizontal mode)
|
||||
spawnY: number // Y position where this session was spawned from parent
|
||||
items: Array<{
|
||||
nodeId: string
|
||||
type: 'session' | 'action' | 'exec'
|
||||
timestamp: number
|
||||
data: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout algorithm:
|
||||
* - Vertical (TB): All roots in column 0, subagents spawn to the right based on depth
|
||||
* - Horizontal (LR): Each root gets its own column group, subagents spawn further right
|
||||
* - Subagents positioned at the Y-level where they were spawned (timeline style)
|
||||
*/
|
||||
export function layoutGraph(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: LayoutOptions = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const {
|
||||
direction = 'TB',
|
||||
nodeWidth = 200,
|
||||
nodeHeight = 80,
|
||||
rankSep = 80,
|
||||
nodeSep = 40,
|
||||
} = options
|
||||
const direction = options.direction ?? 'LR'
|
||||
const isHorizontal = direction === 'LR' || direction === 'RL'
|
||||
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
|
||||
// Build session hierarchy and columns
|
||||
const sessions = nodes
|
||||
.filter((n) => n.type === 'session')
|
||||
.map((n) => n.data as unknown as MonitorSession)
|
||||
|
||||
// Add nodes
|
||||
for (const node of nodes) {
|
||||
const width = node.measured?.width ?? nodeWidth
|
||||
const height = node.measured?.height ?? nodeHeight
|
||||
g.setNode(node.id, { width, height })
|
||||
}
|
||||
const actions = nodes
|
||||
.filter((n) => n.type === 'action')
|
||||
.map((n) => ({ id: n.id.replace('action-', ''), data: n.data as unknown as MonitorAction }))
|
||||
|
||||
// Add edges
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target)
|
||||
}
|
||||
const execs = nodes
|
||||
.filter((n) => n.type === 'exec')
|
||||
.map((n) => ({ id: n.id.replace('exec-', ''), data: n.data as unknown as MonitorExecProcess }))
|
||||
|
||||
// Run layout
|
||||
dagre.layout(g)
|
||||
const crabNode = nodes.find((n) => n.type === 'crab')
|
||||
|
||||
// Apply positions
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id)
|
||||
const width = node.measured?.width ?? nodeWidth
|
||||
const height = node.measured?.height ?? nodeHeight
|
||||
// Build session column map - which column is each session in?
|
||||
const sessionColumns = new Map<string, SessionColumn>()
|
||||
const columnOccupancy = new Map<number, number>() // columnIndex -> maxY used
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - width / 2,
|
||||
y: nodeWithPosition.y - height / 2,
|
||||
},
|
||||
// Find root sessions and build root index map
|
||||
const rootSessions: MonitorSession[] = []
|
||||
const sessionToRoot = new Map<string, number>() // sessionKey -> rootIndex
|
||||
|
||||
// First identify all roots
|
||||
for (const session of sessions) {
|
||||
if (!session.spawnedBy || !sessions.find(s => s.key === session.spawnedBy)) {
|
||||
rootSessions.push(session)
|
||||
}
|
||||
}
|
||||
// Sort by session key for stable ordering - lastActivityAt changes during streaming
|
||||
// which would cause nodes to swap positions
|
||||
rootSessions.sort((a, b) => a.key.localeCompare(b.key))
|
||||
|
||||
// Assign root index to each root
|
||||
rootSessions.forEach((root, idx) => sessionToRoot.set(root.key, idx))
|
||||
|
||||
// Find root for any session by walking up the spawn chain
|
||||
const findRootIndex = (sessionKey: string, visited = new Set<string>()): number => {
|
||||
if (visited.has(sessionKey)) return 0
|
||||
visited.add(sessionKey)
|
||||
|
||||
if (sessionToRoot.has(sessionKey)) {
|
||||
return sessionToRoot.get(sessionKey)!
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => s.key === sessionKey)
|
||||
if (!session || !session.spawnedBy) return 0
|
||||
|
||||
const rootIdx = findRootIndex(session.spawnedBy, visited)
|
||||
sessionToRoot.set(sessionKey, rootIdx)
|
||||
return rootIdx
|
||||
}
|
||||
|
||||
// Determine column for each session based on spawn hierarchy
|
||||
const getSessionDepth = (sessionKey: string, visited = new Set<string>()): number => {
|
||||
if (visited.has(sessionKey)) return 0
|
||||
visited.add(sessionKey)
|
||||
|
||||
const session = sessions.find((s) => s.key === sessionKey)
|
||||
if (!session) return 0
|
||||
|
||||
if (session.spawnedBy) {
|
||||
return getSessionDepth(session.spawnedBy, visited) + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Assign columns to all sessions
|
||||
for (const session of sessions) {
|
||||
const depth = getSessionDepth(session.key)
|
||||
const rootIndex = findRootIndex(session.key)
|
||||
sessionColumns.set(session.key, {
|
||||
sessionKey: session.key,
|
||||
columnIndex: depth,
|
||||
rootIndex,
|
||||
spawnY: depth === 0 ? ROOT_START_Y : 0, // Root sessions start below crab
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Group actions by session and sort by timestamp
|
||||
const actionsBySession = new Map<string, typeof actions>()
|
||||
for (const action of actions) {
|
||||
const sessionKey = action.data.sessionKey
|
||||
if (!sessionKey) continue
|
||||
const list = actionsBySession.get(sessionKey) ?? []
|
||||
list.push(action)
|
||||
actionsBySession.set(sessionKey, list)
|
||||
}
|
||||
for (const [key, list] of actionsBySession) {
|
||||
list.sort((a, b) => a.data.timestamp - b.data.timestamp)
|
||||
actionsBySession.set(key, list)
|
||||
}
|
||||
|
||||
// Group execs by session
|
||||
const execsBySession = new Map<string, typeof execs>()
|
||||
for (const exec of execs) {
|
||||
const sessionKey = exec.data.sessionKey
|
||||
if (!sessionKey) continue
|
||||
const list = execsBySession.get(sessionKey) ?? []
|
||||
list.push(exec)
|
||||
execsBySession.set(sessionKey, list)
|
||||
}
|
||||
for (const [key, list] of execsBySession) {
|
||||
list.sort((a, b) => a.data.startedAt - b.data.startedAt)
|
||||
execsBySession.set(key, list)
|
||||
}
|
||||
|
||||
// Build items list for each session (session node + actions + execs)
|
||||
for (const session of sessions) {
|
||||
const col = sessionColumns.get(session.key)
|
||||
if (!col) continue
|
||||
|
||||
// Add session node itself
|
||||
col.items.push({
|
||||
nodeId: `session-${session.key}`,
|
||||
type: 'session',
|
||||
timestamp: session.lastActivityAt ?? 0,
|
||||
data: session,
|
||||
})
|
||||
|
||||
// Add actions
|
||||
const sessionActions = actionsBySession.get(session.key) ?? []
|
||||
for (const action of sessionActions) {
|
||||
col.items.push({
|
||||
nodeId: `action-${action.id}`,
|
||||
type: 'action',
|
||||
timestamp: action.data.timestamp,
|
||||
data: action.data,
|
||||
})
|
||||
}
|
||||
|
||||
// Add execs
|
||||
const sessionExecs = execsBySession.get(session.key) ?? []
|
||||
for (const exec of sessionExecs) {
|
||||
col.items.push({
|
||||
nodeId: `exec-${exec.id}`,
|
||||
type: 'exec',
|
||||
timestamp: exec.data.startedAt,
|
||||
data: exec.data,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort all items by timestamp (session node first since it's the start)
|
||||
col.items.sort((a, b) => {
|
||||
if (a.type === 'session') return -1
|
||||
if (b.type === 'session') return 1
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate spawn Y positions for child sessions
|
||||
// When a session is spawned, find the Y position of the parent at that time
|
||||
// Cache these values so they don't jitter as parent actions accumulate
|
||||
for (const session of sessions) {
|
||||
if (!session.spawnedBy) continue
|
||||
|
||||
const parentCol = sessionColumns.get(session.spawnedBy)
|
||||
const childCol = sessionColumns.get(session.key)
|
||||
if (!parentCol || !childCol) continue
|
||||
|
||||
// Use cached spawn Y if available (prevents jitter from recalculation)
|
||||
const cachedSpawnY = spawnYCache.get(session.key)
|
||||
if (cachedSpawnY !== undefined) {
|
||||
childCol.spawnY = cachedSpawnY
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the approximate position in parent where spawn happened
|
||||
// Use the child's creation time (approximated by first action time or session activity)
|
||||
const childActions = actionsBySession.get(session.key) ?? []
|
||||
const childCreationTime = childActions[0]?.data.timestamp ?? session.lastActivityAt ?? Date.now()
|
||||
|
||||
// Count how many items in parent were before this spawn
|
||||
let parentItemsBeforeSpawn = 0
|
||||
for (const item of parentCol.items) {
|
||||
if (item.type === 'session') {
|
||||
parentItemsBeforeSpawn++
|
||||
continue
|
||||
}
|
||||
if (item.timestamp <= childCreationTime) {
|
||||
parentItemsBeforeSpawn++
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y based on parent's item count and cache it
|
||||
const calculatedSpawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
|
||||
spawnYCache.set(session.key, calculatedSpawnY)
|
||||
childCol.spawnY = calculatedSpawnY
|
||||
}
|
||||
|
||||
// Position all nodes
|
||||
const positionedNodes: Node[] = []
|
||||
const positionedNodeIds = new Set<string>()
|
||||
|
||||
// Position crab node
|
||||
if (crabNode) {
|
||||
positionedNodes.push({
|
||||
...crabNode,
|
||||
position: { x: CRAB_OFFSET.x, y: CRAB_OFFSET.y },
|
||||
})
|
||||
positionedNodeIds.add(crabNode.id)
|
||||
}
|
||||
|
||||
// Track column usage for collision avoidance
|
||||
// In horizontal mode, we track per (rootIndex, columnIndex)
|
||||
// In vertical mode, we track per columnIndex only
|
||||
const columnRanges = new Map<string, Array<{ startY: number; endY: number }>>()
|
||||
|
||||
const getColumnKey = (rootIndex: number, columnIndex: number): string => {
|
||||
return isHorizontal ? `${rootIndex}-${columnIndex}` : `${columnIndex}`
|
||||
}
|
||||
|
||||
// Get X position for a session
|
||||
const getColumnX = (rootIndex: number, columnIndex: number): number => {
|
||||
if (isHorizontal) {
|
||||
// Each root tree gets its own horizontal space
|
||||
// Root at rootIndex * (maxDepth * COLUMN_GAP + ROOT_HORIZONTAL_GAP)
|
||||
// Plus columnIndex * COLUMN_GAP for depth within tree
|
||||
const maxDepth = Math.max(...Array.from(sessionColumns.values()).map(c => c.columnIndex)) + 1
|
||||
const treeWidth = maxDepth * COLUMN_GAP
|
||||
return rootIndex * (treeWidth + ROOT_HORIZONTAL_GAP) + columnIndex * COLUMN_GAP
|
||||
} else {
|
||||
// Vertical: all sessions at same depth share X
|
||||
return columnIndex * COLUMN_GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust spawn Y to avoid collisions with existing sessions in same column
|
||||
const adjustSpawnY = (rootIndex: number, columnIndex: number, desiredY: number, itemCount: number): number => {
|
||||
const key = getColumnKey(rootIndex, columnIndex)
|
||||
const ranges = columnRanges.get(key) ?? []
|
||||
const estimatedHeight = itemCount * (NODE_DIMENSIONS.action.height + ROW_GAP) + MIN_SESSION_GAP
|
||||
|
||||
let adjustedY = desiredY
|
||||
|
||||
// Check for overlaps and shift down if needed
|
||||
for (const range of ranges) {
|
||||
if (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) {
|
||||
adjustedY = range.endY + MIN_SESSION_GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Record our range
|
||||
ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight })
|
||||
columnRanges.set(key, ranges)
|
||||
|
||||
return adjustedY
|
||||
}
|
||||
|
||||
// Sort sessions by column index (process column 0 first, then 1, etc.)
|
||||
// This ensures parent sessions are positioned before children
|
||||
const sortedSessionKeys = Array.from(sessionColumns.keys()).sort((a, b) => {
|
||||
const colA = sessionColumns.get(a)!
|
||||
const colB = sessionColumns.get(b)!
|
||||
// First by root index (in horizontal mode)
|
||||
if (isHorizontal && colA.rootIndex !== colB.rootIndex) {
|
||||
return colA.rootIndex - colB.rootIndex
|
||||
}
|
||||
// Then by column index (depth)
|
||||
if (colA.columnIndex !== colB.columnIndex) {
|
||||
return colA.columnIndex - colB.columnIndex
|
||||
}
|
||||
// Within same column, sort by spawn Y (earlier spawns first)
|
||||
return colA.spawnY - colB.spawnY
|
||||
})
|
||||
|
||||
return { nodes: layoutedNodes, edges }
|
||||
// Position each session's column
|
||||
for (const sessionKey of sortedSessionKeys) {
|
||||
const col = sessionColumns.get(sessionKey)!
|
||||
const columnX = getColumnX(col.rootIndex, col.columnIndex)
|
||||
|
||||
// Adjust Y position to avoid collisions with other sessions in same column
|
||||
const adjustedY = adjustSpawnY(col.rootIndex, col.columnIndex, col.spawnY, col.items.length)
|
||||
let currentY = adjustedY
|
||||
|
||||
for (const item of col.items) {
|
||||
const dims = NODE_DIMENSIONS[item.type]
|
||||
|
||||
positionedNodes.push({
|
||||
id: item.nodeId,
|
||||
type: item.type,
|
||||
position: { x: columnX, y: currentY },
|
||||
data: nodeData(item.data),
|
||||
})
|
||||
positionedNodeIds.add(item.nodeId)
|
||||
|
||||
currentY += dims.height + ROW_GAP
|
||||
}
|
||||
|
||||
// Track max Y for this column
|
||||
columnOccupancy.set(col.columnIndex, Math.max(
|
||||
columnOccupancy.get(col.columnIndex) ?? 0,
|
||||
currentY
|
||||
))
|
||||
}
|
||||
|
||||
// Handle orphan nodes (actions/execs without a session)
|
||||
let orphanY = Math.max(...Array.from(columnOccupancy.values()), 0) + 100
|
||||
for (const node of nodes) {
|
||||
if (!positionedNodeIds.has(node.id)) {
|
||||
const dims = NODE_DIMENSIONS[node.type as keyof typeof NODE_DIMENSIONS] ?? { width: 180, height: 80 }
|
||||
positionedNodes.push({
|
||||
...node,
|
||||
position: { x: -200, y: orphanY },
|
||||
})
|
||||
orphanY += dims.height + ROW_GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up spawn Y cache for sessions that no longer exist
|
||||
const currentSessionKeys = new Set(sessions.map(s => s.key))
|
||||
for (const key of spawnYCache.keys()) {
|
||||
if (!currentSessionKeys.has(key)) {
|
||||
spawnYCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: positionedNodes, edges }
|
||||
}
|
||||
|
||||
// Group nodes by session for better visual organization
|
||||
export function groupNodesBySession(
|
||||
nodes: Node[]
|
||||
): Map<string, Node[]> {
|
||||
export function groupNodesBySession(nodes: Node[]): Map<string, Node[]> {
|
||||
const groups = new Map<string, Node[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* File system utilities for workspace explorer
|
||||
* Provides safe directory traversal and file reading operations
|
||||
*/
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory'
|
||||
path: string
|
||||
extension?: string
|
||||
size?: number
|
||||
modifiedAt?: Date
|
||||
}
|
||||
|
||||
export interface FileContent {
|
||||
content: string
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path is within the allowed workspace root
|
||||
* Prevents directory traversal attacks
|
||||
*/
|
||||
export function validatePath(workspaceRoot: string, targetPath: string): string {
|
||||
// Resolve to absolute paths
|
||||
const resolvedRoot = path.resolve(workspaceRoot)
|
||||
const resolvedTarget = path.resolve(targetPath)
|
||||
|
||||
// Normalize paths for cross-platform comparison
|
||||
// Convert backslashes to forward slashes and ensure consistent formatting
|
||||
const normalizeForComparison = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
const normalizedRoot = normalizeForComparison(resolvedRoot) + '/'
|
||||
const normalizedTarget = normalizeForComparison(resolvedTarget)
|
||||
|
||||
// Ensure target path is within root path by checking with trailing separator
|
||||
// This prevents bypasses like /home/user/workspace-evil matching /home/user/workspace
|
||||
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(resolvedRoot)) {
|
||||
throw new Error('Path traversal detected: target path is outside workspace root')
|
||||
}
|
||||
|
||||
return resolvedTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists directory contents
|
||||
* Returns files and directories with their types
|
||||
*/
|
||||
export async function listDirectory(
|
||||
workspaceRoot: string,
|
||||
targetPath: string
|
||||
): Promise<DirectoryEntry[]> {
|
||||
const safePath = validatePath(workspaceRoot, targetPath)
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||
|
||||
const result: DirectoryEntry[] = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(targetPath, entry.name)
|
||||
const ext = entry.isFile() ? path.extname(entry.name).toLowerCase() : undefined
|
||||
const isFile = entry.isFile()
|
||||
|
||||
// Get file stats for metadata
|
||||
let size: number | undefined
|
||||
let modifiedAt: Date | undefined
|
||||
try {
|
||||
const stats = await fs.stat(path.join(safePath, entry.name))
|
||||
size = isFile ? stats.size : undefined
|
||||
modifiedAt = stats.mtime
|
||||
} catch {
|
||||
// Stats unavailable, continue without metadata
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
path: entryPath,
|
||||
extension: ext,
|
||||
size,
|
||||
modifiedAt,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
result.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
return a.type === 'directory' ? -1 : 1
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to list directory: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file contents
|
||||
* Only reads text files (markdown, json, txt, etc.)
|
||||
*/
|
||||
export async function readFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string
|
||||
): Promise<FileContent> {
|
||||
const safePath = validatePath(workspaceRoot, filePath)
|
||||
|
||||
try {
|
||||
// Check if file exists and is a file
|
||||
const stats = await fs.stat(safePath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Path is not a file')
|
||||
}
|
||||
|
||||
// Check file size (limit to 10MB)
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
||||
if (stats.size > maxSize) {
|
||||
throw new Error('File too large (max 10MB)')
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(safePath, 'utf-8')
|
||||
const name = path.basename(safePath)
|
||||
|
||||
return {
|
||||
content,
|
||||
path: filePath,
|
||||
name,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path exists and is accessible
|
||||
*/
|
||||
export async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default workspace path
|
||||
* Returns the user's home directory + .openclaw/workspace
|
||||
*/
|
||||
export function getDefaultWorkspacePath(): string {
|
||||
const homeDir = os.homedir()
|
||||
return path.join(homeDir, '.openclaw', 'workspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is a markdown file based on extension
|
||||
*/
|
||||
export function isMarkdownFile(filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
return ext === '.md' || ext === '.markdown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands tilde (~) to the user's home directory on Unix-based systems
|
||||
* Handles both "~/" prefix and standalone "~" path
|
||||
*/
|
||||
export function expandTilde(inputPath: string): string {
|
||||
// Only expand if path starts with ~
|
||||
if (!inputPath.startsWith('~')) {
|
||||
return inputPath
|
||||
}
|
||||
|
||||
// Get home directory using Node.js built-in (handles cross-platform)
|
||||
// Returns /root in containerized environments if HOME is not set
|
||||
const homeDir = os.homedir()
|
||||
|
||||
// Handle "~/" prefix or standalone "~"
|
||||
if (inputPath === '~' || inputPath.startsWith('~/')) {
|
||||
return path.join(homeDir, inputPath.slice(1))
|
||||
}
|
||||
|
||||
// Path starts with ~ but not followed by / (e.g., ~username)
|
||||
// This is a valid Unix path referring to another user's home
|
||||
// Return as-is and let the system handle it
|
||||
return inputPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is viewable as text
|
||||
*/
|
||||
export function isTextFile(filename: string): boolean {
|
||||
const textExtensions = [
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.txt',
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.css',
|
||||
'.html',
|
||||
'.xml',
|
||||
'.sh',
|
||||
'.bash',
|
||||
'.zsh',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.go',
|
||||
'.rs',
|
||||
'.java',
|
||||
'.c',
|
||||
'.cpp',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.cs',
|
||||
'.php',
|
||||
'.swift',
|
||||
'.kt',
|
||||
'.scala',
|
||||
'.r',
|
||||
'.pl',
|
||||
'.lua',
|
||||
'.vim',
|
||||
'.conf',
|
||||
'.cfg',
|
||||
'.ini',
|
||||
'.toml',
|
||||
'.env',
|
||||
'.gitignore',
|
||||
'.dockerignore',
|
||||
]
|
||||
// Get extension - handle files starting with dot (like .gitignore)
|
||||
// path.extname returns '' for files like 'Makefile' and '.gitignore'
|
||||
// We need to distinguish between extensionless files and dotfiles
|
||||
const lastDotIndex = filename.lastIndexOf('.')
|
||||
const ext = lastDotIndex > 0 ? path.extname(filename).toLowerCase() : ''
|
||||
return textExtensions.includes(ext) || ext === ''
|
||||
}
|
||||
+14
-5
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Github } from 'lucide-react'
|
||||
import { Github, FolderOpen } from 'lucide-react'
|
||||
import { version } from '../../package.json'
|
||||
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
|
||||
|
||||
function XIcon({ size = 14, className }: { size?: number; className?: string }) {
|
||||
@@ -122,7 +123,7 @@ function Home() {
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="font-console font-bold text-lg text-gray-400 mb-4 tracking-wide uppercase"
|
||||
>
|
||||
Open-Source Moltbot (Clawdbot) Companion
|
||||
Open-Source OpenClaw Companion
|
||||
</motion.p>
|
||||
|
||||
{/* Console-style description */}
|
||||
@@ -134,18 +135,26 @@ function Home() {
|
||||
>
|
||||
<span className="text-crab-600">></span> Real-time AI agent activity monitoring<br />
|
||||
<span className="text-crab-600">></span> Session tracking & action visualization<br />
|
||||
<span className="text-crab-600">></span> Multi-platform gateway interface
|
||||
<span className="text-crab-600">></span> Workspace file browser & markdown viewer
|
||||
</motion.div>
|
||||
|
||||
{/* CTA Button */}
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
>
|
||||
<Link to="/monitor" className="btn-retro inline-block rounded-lg font-black!">
|
||||
Launch Monitor
|
||||
</Link>
|
||||
<Link
|
||||
to="/workspace"
|
||||
className="btn-retro btn-retro-secondary inline-flex items-center gap-2 rounded-lg font-black!"
|
||||
>
|
||||
<FolderOpen size={18} />
|
||||
Explore Workspace
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Decorative line */}
|
||||
@@ -165,7 +174,7 @@ function Home() {
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
|
||||
<span className="font-console font-bold text-[11px] uppercase text-shell-500">
|
||||
system online • v1.0.1
|
||||
system online • v{version}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useLiveQuery } from '@tanstack/react-db'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Loader2, HardDrive } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import {
|
||||
sessionsCollection,
|
||||
actionsCollection,
|
||||
execsCollection,
|
||||
upsertSession,
|
||||
addAction,
|
||||
addExecEvent,
|
||||
updateSessionStatus,
|
||||
clearCollections,
|
||||
hydrateFromServer,
|
||||
} from '~/integrations/clawdbot'
|
||||
clearCompletedExecs,
|
||||
} from '~/integrations/openclaw'
|
||||
import {
|
||||
ActionGraph,
|
||||
SessionList,
|
||||
@@ -83,9 +86,22 @@ function MonitorPage() {
|
||||
// Live queries from TanStack DB collections
|
||||
const sessionsQuery = useLiveQuery(sessionsCollection)
|
||||
const actionsQuery = useLiveQuery(actionsCollection)
|
||||
const execsQuery = useLiveQuery(execsCollection)
|
||||
|
||||
const sessions = sessionsQuery.data ?? []
|
||||
const actions = actionsQuery.data ?? []
|
||||
const execs = execsQuery.data ?? []
|
||||
|
||||
// Count clearable items (completed/failed execs)
|
||||
const completedCount = useMemo(() => {
|
||||
return execs.filter(e => e.status === 'completed' || e.status === 'failed').length
|
||||
}, [execs])
|
||||
|
||||
// Handler for clearing completed execs
|
||||
const handleClearCompleted = useCallback(() => {
|
||||
const count = clearCompletedExecs()
|
||||
console.log(`[monitor] cleared ${count} completed execs`)
|
||||
}, [])
|
||||
|
||||
|
||||
// Check connection status and persistence on mount
|
||||
@@ -96,7 +112,7 @@ function MonitorPage() {
|
||||
|
||||
const checkPersistenceStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
@@ -108,7 +124,7 @@ function MonitorPage() {
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.status.query()
|
||||
const status = await trpc.openclaw.status.query()
|
||||
setConnected(status.connected)
|
||||
} catch {
|
||||
setConnected(false)
|
||||
@@ -119,7 +135,7 @@ function MonitorPage() {
|
||||
setConnecting(true)
|
||||
setRetryCount(retry)
|
||||
try {
|
||||
const result = await trpc.clawdbot.connect.mutate()
|
||||
const result = await trpc.openclaw.connect.mutate()
|
||||
if (result.status === 'connected' || result.status === 'already_connected') {
|
||||
setConnected(true)
|
||||
setRetryCount(0)
|
||||
@@ -142,11 +158,13 @@ function MonitorPage() {
|
||||
|
||||
const hydrateFromPersistence = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
if (status.sessionCount > 0 || status.actionCount > 0) {
|
||||
const data = await trpc.clawdbot.persistenceHydrate.query()
|
||||
hydrateFromServer(data.sessions, data.actions)
|
||||
console.log(`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions`)
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
|
||||
const data = await trpc.openclaw.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`
|
||||
)
|
||||
}
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
@@ -159,7 +177,7 @@ function MonitorPage() {
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.disconnect.mutate()
|
||||
await trpc.openclaw.disconnect.mutate()
|
||||
setConnected(false)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
@@ -169,7 +187,7 @@ function MonitorPage() {
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.sessions.query(
|
||||
const result = await trpc.openclaw.sessions.query(
|
||||
historicalMode ? { activeMinutes: 1440 } : { activeMinutes: 60 }
|
||||
)
|
||||
if (result.sessions) {
|
||||
@@ -196,7 +214,7 @@ function MonitorPage() {
|
||||
const handleDebugModeChange = async (enabled: boolean) => {
|
||||
setDebugMode(enabled)
|
||||
try {
|
||||
await trpc.clawdbot.setDebugMode.mutate({ enabled })
|
||||
await trpc.openclaw.setDebugMode.mutate({ enabled })
|
||||
} catch (e) {
|
||||
console.error('Failed to set debug mode:', e)
|
||||
}
|
||||
@@ -205,7 +223,7 @@ function MonitorPage() {
|
||||
const handleLogCollectionChange = async (enabled: boolean) => {
|
||||
setLogCollection(enabled)
|
||||
try {
|
||||
const result = await trpc.clawdbot.setLogCollection.mutate({ enabled })
|
||||
const result = await trpc.openclaw.setLogCollection.mutate({ enabled })
|
||||
setLogCount(result.eventCount)
|
||||
} catch (e) {
|
||||
console.error('Failed to set log collection:', e)
|
||||
@@ -214,12 +232,12 @@ function MonitorPage() {
|
||||
|
||||
const handleDownloadLogs = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.downloadLogs.query()
|
||||
const result = await trpc.openclaw.downloadLogs.query()
|
||||
const blob = new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `clawdbot-events-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`
|
||||
a.download = `openclaw-events-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
@@ -231,7 +249,7 @@ function MonitorPage() {
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.clearLogs.mutate()
|
||||
await trpc.openclaw.clearLogs.mutate()
|
||||
setLogCount(0)
|
||||
} catch (e) {
|
||||
console.error('Failed to clear logs:', e)
|
||||
@@ -240,7 +258,7 @@ function MonitorPage() {
|
||||
|
||||
const handlePersistenceStart = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.persistenceStart.mutate()
|
||||
const result = await trpc.openclaw.persistenceStart.mutate()
|
||||
setPersistenceEnabled(result.enabled)
|
||||
setPersistenceStartedAt(result.startedAt)
|
||||
} catch (e) {
|
||||
@@ -250,7 +268,7 @@ function MonitorPage() {
|
||||
|
||||
const handlePersistenceStop = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.persistenceStop.mutate()
|
||||
const result = await trpc.openclaw.persistenceStop.mutate()
|
||||
setPersistenceEnabled(result.enabled)
|
||||
setPersistenceStartedAt(null)
|
||||
} catch (e) {
|
||||
@@ -260,7 +278,7 @@ function MonitorPage() {
|
||||
|
||||
const handlePersistenceClear = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.persistenceClear.mutate()
|
||||
await trpc.openclaw.persistenceClear.mutate()
|
||||
setPersistenceSessionCount(0)
|
||||
setPersistenceActionCount(0)
|
||||
clearCollections()
|
||||
@@ -274,7 +292,7 @@ function MonitorPage() {
|
||||
if (!logCollection) return
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.getLogCollection.query()
|
||||
const result = await trpc.openclaw.getLogCollection.query()
|
||||
setLogCount(result.eventCount)
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -287,7 +305,7 @@ function MonitorPage() {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
@@ -323,7 +341,7 @@ function MonitorPage() {
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
|
||||
const subscription = trpc.clawdbot.events.subscribe(undefined, {
|
||||
const subscription = trpc.openclaw.events.subscribe(undefined, {
|
||||
onData: (data) => {
|
||||
if (data.type === 'session' && data.session?.key && data.session.status) {
|
||||
updateSessionStatus(data.session.key, data.session.status)
|
||||
@@ -331,6 +349,9 @@ function MonitorPage() {
|
||||
if (data.type === 'action' && data.action) {
|
||||
addAction(data.action)
|
||||
}
|
||||
if (data.type === 'exec' && data.execEvent) {
|
||||
addExecEvent(data.execEvent)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('[monitor] subscription error:', err)
|
||||
@@ -357,16 +378,29 @@ function MonitorPage() {
|
||||
<ArrowLeft size={18} className="text-gray-400 group-hover:text-crab-400" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-7 h-7" />
|
||||
{/* Navigation tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Monitor tab - active */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
MONITOR
|
||||
</span>
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
</div>
|
||||
<h1 className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
MONITOR
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
{/* Workspace tab - inactive */}
|
||||
<Link
|
||||
to="/workspace"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
|
||||
>
|
||||
<span className="font-arcade text-xs text-gray-500 tracking-wider">
|
||||
WORKSPACE
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-4">
|
||||
@@ -383,6 +417,23 @@ function MonitorPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Clear Completed button */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all bg-shell-800/50 hover:bg-crab-900/50 hover:border-crab-700/50 border border-transparent group"
|
||||
title={`Clear ${completedCount} completed item${completedCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Trash2
|
||||
size={14}
|
||||
className="text-shell-400 group-hover:text-crab-400 transition-colors"
|
||||
/>
|
||||
<span className="font-console text-xs text-shell-400 group-hover:text-crab-400 transition-colors">
|
||||
{completedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Persistence indicator */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
@@ -458,6 +509,7 @@ function MonitorPage() {
|
||||
<ActionGraph
|
||||
sessions={sessions}
|
||||
actions={actions}
|
||||
execs={execs}
|
||||
selectedSession={selectedSession}
|
||||
onSessionSelect={setSelectedSession}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import { FileTree, MarkdownViewer } from '~/components/workspace'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
// Get parent directory path using path separator logic
|
||||
// Works cross-platform for both / and \ separators
|
||||
function getParentDirPath(filePath: string): string {
|
||||
// Normalize to forward slashes for consistent processing
|
||||
const normalized = filePath.replace(/\\/g, '/')
|
||||
const lastSlashIndex = normalized.lastIndexOf('/')
|
||||
if (lastSlashIndex <= 0) {
|
||||
return filePath
|
||||
}
|
||||
// Return the original path up to the last separator
|
||||
return filePath.substring(0, lastSlashIndex)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/workspace/')({
|
||||
component: WorkspacePageWrapper,
|
||||
})
|
||||
|
||||
// Wrapper to ensure client-only rendering
|
||||
function WorkspacePageWrapper() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-shell-950 text-white">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-16 h-16" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-display text-sm text-gray-400 tracking-wide uppercase">
|
||||
Loading Workspace...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <WorkspacePage />
|
||||
}
|
||||
|
||||
function WorkspacePage() {
|
||||
// Workspace path state
|
||||
const [workspacePath, setWorkspacePath] = useState('')
|
||||
const [workspacePathInput, setWorkspacePathInput] = useState('')
|
||||
const [pathError, setPathError] = useState<string | null>(null)
|
||||
const [pathValid, setPathValid] = useState(false)
|
||||
|
||||
// File tree state
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pathCache, setPathCache] = useState<Map<string, DirectoryEntry[]>>(new Map())
|
||||
|
||||
// Selected file state
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [selectedFileContent, setSelectedFileContent] = useState('')
|
||||
const [selectedFileName, setSelectedFileName] = useState('')
|
||||
const [selectedFileSize, setSelectedFileSize] = useState<number | undefined>()
|
||||
const [selectedFileModified, setSelectedFileModified] = useState<Date | undefined>()
|
||||
const [fileError, setFileError] = useState<string | undefined>()
|
||||
|
||||
// Sidebar collapse state
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
// Root entries for FileTree
|
||||
const rootEntries = workspacePath && pathValid ? (pathCache.get(workspacePath) || []) : []
|
||||
|
||||
// Load saved path or default on mount
|
||||
useEffect(() => {
|
||||
const savedPath = localStorage.getItem('crabcrawl:workspacePath')
|
||||
if (savedPath) {
|
||||
setWorkspacePathInput(savedPath)
|
||||
// Auto-validate saved path
|
||||
validatePathAndSet(savedPath)
|
||||
} else {
|
||||
loadDefaultPath()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load entries when workspace path changes and is valid
|
||||
useEffect(() => {
|
||||
if (workspacePath && pathValid) {
|
||||
loadDirectory(workspacePath)
|
||||
}
|
||||
}, [workspacePath, pathValid])
|
||||
|
||||
const loadDefaultPath = async () => {
|
||||
try {
|
||||
const result = await trpc.workspace.getDefaultPath.query()
|
||||
setWorkspacePathInput(result.path)
|
||||
// Don't auto-set workspace path - let user confirm
|
||||
} catch (error) {
|
||||
console.error('Failed to get default path:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const validatePathAndSet = async (pathToValidate: string) => {
|
||||
setPathError(null)
|
||||
setPathValid(false)
|
||||
|
||||
if (!pathToValidate.trim()) {
|
||||
setPathError('Please enter a path')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await trpc.workspace.validatePath.query({
|
||||
path: pathToValidate,
|
||||
})
|
||||
|
||||
if (result.valid && result.expandedPath) {
|
||||
// Use the expanded path (e.g., ~/Documents -> /home/user/Documents)
|
||||
setWorkspacePath(result.expandedPath)
|
||||
setWorkspacePathInput(result.expandedPath)
|
||||
setPathValid(true)
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('crabcrawl:workspacePath', result.expandedPath)
|
||||
// Clear cache when path changes
|
||||
setPathCache(new Map())
|
||||
setSelectedPath(null)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
} else {
|
||||
setPathError(result.error || 'Invalid path')
|
||||
}
|
||||
} catch (error) {
|
||||
setPathError(error instanceof Error ? error.message : 'Failed to validate path')
|
||||
}
|
||||
}
|
||||
|
||||
const validateAndSetPath = async () => {
|
||||
await validatePathAndSet(workspacePathInput)
|
||||
}
|
||||
|
||||
const loadDirectory = async (dirPath: string): Promise<DirectoryEntry[]> => {
|
||||
// Check cache first
|
||||
if (pathCache.has(dirPath)) {
|
||||
return pathCache.get(dirPath)!
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await trpc.workspace.listDirectory.query({
|
||||
workspaceRoot: workspacePath,
|
||||
path: dirPath,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
setPathCache((prev) => new Map(prev).set(dirPath, result.entries))
|
||||
return result.entries
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error)
|
||||
return []
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
setFileError(undefined)
|
||||
try {
|
||||
const result = await trpc.workspace.readFile.query({
|
||||
workspaceRoot: workspacePath,
|
||||
path: filePath,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setFileError(result.error)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
} else {
|
||||
setSelectedFileContent(result.content)
|
||||
setSelectedFileName(result.name)
|
||||
// Get file metadata from the parent directory entry if available
|
||||
const parentDir = pathCache.get(getParentDirPath(filePath) || workspacePath)
|
||||
const fileEntry = parentDir?.find(e => e.path === filePath)
|
||||
setSelectedFileSize(fileEntry?.size)
|
||||
setSelectedFileModified(fileEntry?.modifiedAt)
|
||||
}
|
||||
} catch (error) {
|
||||
setFileError(error instanceof Error ? error.message : 'Failed to read file')
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
}
|
||||
},
|
||||
[workspacePath, pathCache, selectedPath]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (path: string, type: 'file' | 'directory') => {
|
||||
if (type === 'file') {
|
||||
setSelectedPath(path)
|
||||
await loadFile(path)
|
||||
}
|
||||
// Note: directory expansion is handled by FileTree component internally
|
||||
},
|
||||
[loadFile]
|
||||
)
|
||||
|
||||
// Handle directory loading for FileTree
|
||||
const handleLoadDirectory = useCallback(
|
||||
async (dirPath: string): Promise<DirectoryEntry[]> => {
|
||||
return loadDirectory(dirPath)
|
||||
},
|
||||
[workspacePath]
|
||||
)
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!workspacePath || !pathValid) return
|
||||
|
||||
// Store current selection before clearing cache
|
||||
const currentSelectedPath = selectedPath
|
||||
|
||||
// Clear cache first, then reload
|
||||
// Use a callback to ensure cache is cleared before loading
|
||||
setPathCache(new Map())
|
||||
|
||||
// Small delay to ensure React has processed the state update
|
||||
// before we try to load the directory
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// Reload root directory - this will repopulate the file tree
|
||||
// Force reload by bypassing cache check
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await trpc.workspace.listDirectory.query({
|
||||
workspaceRoot: workspacePath,
|
||||
path: workspacePath,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
// Update cache with fresh data
|
||||
setPathCache(new Map([[workspacePath, result.entries]]))
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Reload selected file if any (with error handling for deleted files)
|
||||
if (currentSelectedPath) {
|
||||
try {
|
||||
const result = await trpc.workspace.readFile.query({
|
||||
workspaceRoot: workspacePath,
|
||||
path: currentSelectedPath,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
// File no longer exists - clear selection gracefully
|
||||
setSelectedPath(null)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
setFileError(result.error)
|
||||
} else {
|
||||
setSelectedFileContent(result.content)
|
||||
setSelectedFileName(result.name)
|
||||
// Get file metadata from the parent directory entry if available
|
||||
const parentDir = pathCache.get(getParentDirPath(currentSelectedPath) || workspacePath)
|
||||
const fileEntry = parentDir?.find(e => e.path === currentSelectedPath)
|
||||
setSelectedFileSize(fileEntry?.size)
|
||||
setSelectedFileModified(fileEntry?.modifiedAt)
|
||||
}
|
||||
} catch (error) {
|
||||
// File no longer exists - clear selection gracefully
|
||||
setSelectedPath(null)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
setFileError(error instanceof Error ? error.message : 'Failed to read file')
|
||||
}
|
||||
}
|
||||
}, [workspacePath, pathValid, selectedPath, loadFile])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
validateAndSetPath()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-3 bg-shell-900 relative">
|
||||
{/* Gradient accent */}
|
||||
<div className="absolute inset-0 bg-linear-to-r from-crab-950/20 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
<div className="relative flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all border border-transparent hover:border-shell-600 group"
|
||||
>
|
||||
<ArrowLeft size={18} className="text-gray-400 group-hover:text-crab-400" />
|
||||
</Link>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Monitor tab - inactive */}
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
|
||||
>
|
||||
<span className="font-arcade text-xs text-gray-500 tracking-wider">
|
||||
MONITOR
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Workspace tab - active */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
WORKSPACE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3 flex-1 max-w-2xl mx-4">
|
||||
{/* Path input */}
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<FolderOpen size={16} className="text-shell-500 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={workspacePathInput}
|
||||
onChange={(e) => setWorkspacePathInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter workspace path..."
|
||||
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20"
|
||||
/>
|
||||
<button
|
||||
onClick={validateAndSetPath}
|
||||
className="px-3 py-1.5 bg-crab-600 hover:bg-crab-500 text-white text-sm font-display rounded-lg transition-colors"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pathError && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 px-3 py-2 bg-crab-900/90 border border-crab-700 rounded-lg flex items-center gap-2 z-50">
|
||||
<AlertCircle size={14} className="text-crab-400" />
|
||||
<span className="text-xs text-crab-200 font-console">{pathError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all border border-transparent hover:border-shell-600 disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={`text-gray-400 group-hover:text-crab-400 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!sidebarCollapsed && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 320, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Sidebar header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-shell-800">
|
||||
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="p-1 hover:bg-shell-800 rounded transition-colors"
|
||||
title="Hide sidebar"
|
||||
>
|
||||
<PanelLeftClose size={14} className="text-shell-500 hover:text-crab-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Enter a workspace path to browse files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
{pathValid && (
|
||||
<div className="px-4 py-2 border-t border-shell-800">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 relative bg-shell-950">
|
||||
{/* Floating sidebar toggle when collapsed */}
|
||||
{sidebarCollapsed && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
className="absolute left-4 top-4 z-10 p-2 bg-shell-800/80 hover:bg-shell-700 rounded-lg border border-shell-700 transition-all"
|
||||
title="Show sidebar"
|
||||
>
|
||||
<PanelLeft size={18} className="text-gray-400 hover:text-crab-400" />
|
||||
</motion.button>
|
||||
)}
|
||||
<MarkdownViewer
|
||||
content={selectedFileContent}
|
||||
fileName={selectedFileName}
|
||||
fileSize={selectedFileSize}
|
||||
fileModified={selectedFileModified}
|
||||
error={fileError}
|
||||
/>
|
||||
</div>
|
||||
</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