mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 09:02:07 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5636167c57 | ||
|
|
5878b15be1 | ||
|
|
c84fc7a2fb | ||
|
|
c001f612b9 | ||
|
|
0b0613e08a | ||
|
|
551543682f | ||
|
|
cf8ac7ca67 | ||
|
|
27d0afd736 | ||
|
|
efb1790964 | ||
|
|
e58e37ca87 | ||
|
|
e19a58054d | ||
|
|
4ef95e6df1 | ||
|
|
87a6e71aa9 | ||
|
|
3ba5b7ee6c | ||
|
|
7e75a12614 | ||
|
|
b91bfeb4a4 | ||
|
|
e0b5ddfec5 | ||
|
|
154c93131e | ||
|
|
50114100a2 | ||
|
|
2ba3ef6d47 |
@@ -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.
|
||||
|
||||
@@ -13,6 +13,10 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
ENV HOME=/root
|
||||
|
||||
# Create workspace directory for volume mounting
|
||||
RUN mkdir -p /root/.openclaw/workspace
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
|
||||
@@ -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,46 @@ 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 Moltbot 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 Moltbot with `bind: loopback` and `tailscale serve` for secure tailnet-only access, you'll need to run the crabwalk container with host networking - replace `p:3000:3000` with `--network host`
|
||||
> 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`
|
||||
@@ -55,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
+259
-240
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
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
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
useOnViewportChange,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
MarkerType,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react'
|
||||
import { LayoutGrid, ArrowRightLeft, ArrowUpDown } from 'lucide-react'
|
||||
import { LayoutGrid, ArrowRightLeft, ArrowUpDown, Crosshair } from 'lucide-react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { SessionNode } from './SessionNode'
|
||||
import { ActionNode } from './ActionNode'
|
||||
@@ -25,7 +27,7 @@ import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecProcess,
|
||||
} from '~/integrations/clawdbot'
|
||||
} from '~/integrations/openclaw'
|
||||
|
||||
interface ActionGraphProps {
|
||||
sessions: MonitorSession[]
|
||||
@@ -100,6 +102,22 @@ function ActionGraphInner({
|
||||
// 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)
|
||||
@@ -457,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')) {
|
||||
@@ -470,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
|
||||
@@ -489,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(() => {
|
||||
@@ -754,6 +784,17 @@ function ActionGraphInner({
|
||||
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'))
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MessageCircle,
|
||||
Bot,
|
||||
} from 'lucide-react'
|
||||
import type { MonitorAction } from '~/integrations/clawdbot'
|
||||
import type { MonitorAction } from '~/integrations/openclaw'
|
||||
|
||||
interface ActionNodeProps {
|
||||
data: MonitorAction
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { CheckCircle, Copy, Check, Loader2, Terminal, XCircle } from 'lucide-react'
|
||||
import type { MonitorExecProcess, MonitorExecOutputChunk } from '~/integrations/clawdbot'
|
||||
import type { MonitorExecProcess, MonitorExecOutputChunk } from '~/integrations/openclaw'
|
||||
|
||||
interface ExecNodeProps {
|
||||
data: MonitorExecProcess
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
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");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Handle, Position } from '@xyflow/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
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,21 +84,21 @@ function inferSpawnedBy(subagentKey: string, timestamp?: number): string | undef
|
||||
|
||||
export const sessionsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorSession>({
|
||||
id: 'clawdbot-sessions',
|
||||
id: 'openclaw-sessions',
|
||||
getKey: (item) => item.key,
|
||||
})
|
||||
)
|
||||
|
||||
export const actionsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorAction>({
|
||||
id: 'clawdbot-actions',
|
||||
id: 'openclaw-actions',
|
||||
getKey: (item) => item.id,
|
||||
})
|
||||
)
|
||||
|
||||
export const execsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorExecProcess>({
|
||||
id: 'clawdbot-execs',
|
||||
id: 'openclaw-execs',
|
||||
getKey: (item) => item.id,
|
||||
})
|
||||
)
|
||||
@@ -2,15 +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,
|
||||
type MonitorExecEvent,
|
||||
} from '~/integrations/clawdbot'
|
||||
} 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
|
||||
@@ -27,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) {
|
||||
@@ -64,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 }
|
||||
}),
|
||||
|
||||
@@ -78,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 }
|
||||
}),
|
||||
@@ -100,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 }
|
||||
}),
|
||||
|
||||
@@ -216,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() }))
|
||||
@@ -231,7 +303,8 @@ export const appRouter = router({
|
||||
]
|
||||
}),
|
||||
|
||||
clawdbot: clawdbotRouter,
|
||||
openclaw: openclawRouter,
|
||||
workspace: workspaceRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
+25
-3
@@ -3,7 +3,7 @@ import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecProcess,
|
||||
} from '~/integrations/clawdbot'
|
||||
} from '~/integrations/openclaw'
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
function nodeData<T>(data: T): Record<string, unknown> {
|
||||
@@ -35,6 +35,10 @@ 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
|
||||
@@ -215,6 +219,7 @@ export function layoutGraph(
|
||||
|
||||
// Calculate spawn Y positions for child sessions
|
||||
// When a session is spawned, find the Y position of the parent at that time
|
||||
// Cache these values so they don't jitter as parent actions accumulate
|
||||
for (const session of sessions) {
|
||||
if (!session.spawnedBy) continue
|
||||
|
||||
@@ -222,6 +227,13 @@ export function layoutGraph(
|
||||
const childCol = sessionColumns.get(session.key)
|
||||
if (!parentCol || !childCol) continue
|
||||
|
||||
// Use cached spawn Y if available (prevents jitter from recalculation)
|
||||
const cachedSpawnY = spawnYCache.get(session.key)
|
||||
if (cachedSpawnY !== undefined) {
|
||||
childCol.spawnY = cachedSpawnY
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the approximate position in parent where spawn happened
|
||||
// Use the child's creation time (approximated by first action time or session activity)
|
||||
const childActions = actionsBySession.get(session.key) ?? []
|
||||
@@ -239,8 +251,10 @@ export function layoutGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y based on parent's item count
|
||||
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
|
||||
// Calculate Y based on parent's item count and cache it
|
||||
const calculatedSpawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
|
||||
spawnYCache.set(session.key, calculatedSpawnY)
|
||||
childCol.spawnY = calculatedSpawnY
|
||||
}
|
||||
|
||||
// Position all nodes
|
||||
@@ -362,6 +376,14 @@ export function layoutGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up spawn Y cache for sessions that no longer exist
|
||||
const currentSessionKeys = new Set(sessions.map(s => s.key))
|
||||
for (const key of spawnYCache.keys()) {
|
||||
if (!currentSessionKeys.has(key)) {
|
||||
spawnYCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: positionedNodes, edges }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 === ''
|
||||
}
|
||||
+12
-4
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Github } from 'lucide-react'
|
||||
import { Github, FolderOpen } from 'lucide-react'
|
||||
import { version } from '../../package.json'
|
||||
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
|
||||
|
||||
@@ -123,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 */}
|
||||
@@ -135,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 */}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
clearCollections,
|
||||
hydrateFromServer,
|
||||
clearCompletedExecs,
|
||||
} from '~/integrations/clawdbot'
|
||||
} from '~/integrations/openclaw'
|
||||
import {
|
||||
ActionGraph,
|
||||
SessionList,
|
||||
@@ -112,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)
|
||||
@@ -124,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)
|
||||
@@ -135,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)
|
||||
@@ -158,9 +158,9 @@ function MonitorPage() {
|
||||
|
||||
const hydrateFromPersistence = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
|
||||
const data = await trpc.clawdbot.persistenceHydrate.query()
|
||||
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`
|
||||
@@ -177,7 +177,7 @@ function MonitorPage() {
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.disconnect.mutate()
|
||||
await trpc.openclaw.disconnect.mutate()
|
||||
setConnected(false)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
@@ -187,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) {
|
||||
@@ -214,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)
|
||||
}
|
||||
@@ -223,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)
|
||||
@@ -232,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)
|
||||
@@ -249,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)
|
||||
@@ -258,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) {
|
||||
@@ -268,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) {
|
||||
@@ -278,7 +278,7 @@ function MonitorPage() {
|
||||
|
||||
const handlePersistenceClear = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.persistenceClear.mutate()
|
||||
await trpc.openclaw.persistenceClear.mutate()
|
||||
setPersistenceSessionCount(0)
|
||||
setPersistenceActionCount(0)
|
||||
clearCollections()
|
||||
@@ -292,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
|
||||
@@ -305,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)
|
||||
@@ -341,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)
|
||||
@@ -378,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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user