mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 09:02:07 +00:00
Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d88c543f | ||
|
|
ea99ca93fd | ||
|
|
6ca27b1e81 | ||
|
|
6a60c6e9cd | ||
|
|
1299d415b8 | ||
|
|
72a4917941 | ||
|
|
00d88aa1a8 | ||
|
|
8dbb6802e6 | ||
|
|
c76d7c3354 | ||
|
|
793d2582d9 | ||
|
|
5c59e9baa5 | ||
|
|
8d076aa9c3 | ||
|
|
56a2cf83be | ||
|
|
80067dec9a | ||
|
|
23699da927 | ||
|
|
600cfca6d6 | ||
|
|
ca6a60d1a9 | ||
|
|
6dc43b08ef | ||
|
|
43e5734833 | ||
|
|
6d3315f9c3 | ||
|
|
f2a8c1dfcb | ||
|
|
7c0794e373 | ||
|
|
34ef562bc1 | ||
|
|
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 | ||
|
|
e690c847fc | ||
|
|
c6990f5d86 | ||
|
|
f5eb110123 | ||
|
|
ad75627e0f | ||
|
|
5e53b2a70c | ||
|
|
78ddc4cea3 | ||
|
|
139f474076 | ||
|
|
9181d11047 | ||
|
|
2155d0cffa | ||
|
|
a132a33c6b | ||
|
|
c39ae8a375 | ||
|
|
6811bf7c60 | ||
|
|
271d0c47b2 | ||
|
|
e403f2788a | ||
|
|
aae5df728c | ||
|
|
6101bd59dd | ||
|
|
e848d6c25f | ||
|
|
a22ce9ab41 | ||
|
|
6ccd394838 | ||
|
|
37a4db51de | ||
|
|
caf185f917 | ||
|
|
c1a6d38c26 | ||
|
|
ef1d8d2052 | ||
|
|
ff50cafe34 | ||
|
|
ad791b25af | ||
|
|
11d642f4e7 |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.output
|
||||
.git
|
||||
.github
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
# Clawdbot gateway auth token
|
||||
CLAWDBOT_API_TOKEN=
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: luccasveg
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Create build artifact
|
||||
run: |
|
||||
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz .output bin package.json
|
||||
|
||||
- name: Upload build to release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: crabwalk-${{ github.ref_name }}.tar.gz
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -34,3 +34,9 @@ src/routeTree.gen.ts
|
||||
|
||||
documents/*
|
||||
.tanstack/tmp/*
|
||||
|
||||
# Persistence data
|
||||
data/
|
||||
|
||||
# coding agent plans
|
||||
plans/
|
||||
@@ -0,0 +1,46 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `src/routes/`: TanStack Start file-based routes (`/monitor`, `/workspace`, API handlers under `src/routes/api/`).
|
||||
- `src/components/`: UI by domain (`monitor/`, `workspace/`, `navigation/`, `ani/`).
|
||||
- `src/integrations/`: external/system integrations (`openclaw/`, `trpc/`, `query/`).
|
||||
- `src/lib/`: shared utilities (graph layout, workspace FS helpers, demo data).
|
||||
- `public/`: static assets (images, fonts, skill metadata).
|
||||
- Runtime and packaging files: `Dockerfile`, `docker-compose.yml`, `bin/crabwalk`.
|
||||
|
||||
## Architecture Overview
|
||||
- Stack: TanStack Start + Router, tRPC, TanStack Query/DB, ReactFlow, Tailwind v4, React 19.
|
||||
- Monitor flow: OpenClaw gateway WebSocket -> server integration (`src/integrations/openclaw/`) -> tRPC router (`src/integrations/trpc/router.ts`) -> client collections/graph UI.
|
||||
- API entrypoint: `src/routes/api/trpc.$.ts`; router setup in `src/router.tsx`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm run dev`: starts local dev server on `http://localhost:3000`.
|
||||
- `npm run build`: creates production build with Vite/TanStack Start.
|
||||
- `npm start`: runs the built server from `.output/server/index.mjs`.
|
||||
- `docker-compose up -d`: run containerized app (set `CLAWDBOT_API_TOKEN` first).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript + React function components.
|
||||
- Style in current codebase: 2-space indentation, single quotes, semicolon-light formatting.
|
||||
- Components/files: `PascalCase` for React components (example: `SessionNode.tsx`).
|
||||
- Hooks/utilities: `camelCase` exports, hooks prefixed with `use` (example: `useIsMobile.ts`).
|
||||
- Route files follow TanStack conventions, e.g. `src/routes/monitor/index.tsx`, `src/routes/api/trpc.$.ts`.
|
||||
- Use path alias `~/` for imports from `src`.
|
||||
|
||||
## Testing Guidelines
|
||||
- No dedicated automated test script is currently defined in `package.json`.
|
||||
- Minimum pre-PR validation: run `npm run build`, then verify `/monitor` connectivity and `/workspace` file operations in `npm run dev`.
|
||||
- If adding tests, colocate as `*.test.ts` / `*.test.tsx` near the feature and prefer fast unit tests for parsing/state logic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow Conventional Commit style used in history: `feat(scope): ...`, `fix(scope): ...`, `docs: ...`, `chore: ...`.
|
||||
- Keep scopes aligned with feature areas (`monitor`, `workspace`, `nav`, `openclaw`).
|
||||
- PRs should include:
|
||||
- clear summary of behavior changes,
|
||||
- linked issue(s) when applicable,
|
||||
- screenshots/GIFs for UI changes,
|
||||
- notes on env/config changes (tokens, gateway URL, workspace mounts).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit secrets. Use `.env` or runtime env vars (`CLAWDBOT_API_TOKEN`, `CLAWDBOT_URL`).
|
||||
- Keep `.env.example` updated when introducing new required configuration.
|
||||
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
```bash
|
||||
npm run dev # start dev server on port 3000
|
||||
npm run build # production build
|
||||
npm start # run production server (.output/server/index.mjs)
|
||||
npm start # run production server (dist/server/server.js)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
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
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🦀 Crabwalk
|
||||
|
||||
Real-time companion monitor for [Clawdbot](https://github.com/clawdbot/clawdbot) 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,32 +12,152 @@ 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
|
||||
|
||||
## Getting Started
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
### 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
|
||||
```
|
||||
|
||||
Open `http://localhost:3000/monitor` (or `http://<server-ip>:3000/monitor` for remote access)
|
||||
|
||||
Requires clawdbot gateway running on the same machine.
|
||||
|
||||
## Config
|
||||
|
||||
Token is in `~/.clawdbot/clawdbot.json`
|
||||
### CLI Install
|
||||
|
||||
```bash
|
||||
# Option 1: command line
|
||||
CLAWDBOT_API_TOKEN=your-token npm run dev
|
||||
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
|
||||
```
|
||||
|
||||
# Option 2: env file
|
||||
echo "CLAWDBOT_API_TOKEN=your-token" > .env.local
|
||||
npm run dev
|
||||
## CLI Usage
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
crabwalk # Start server (default: 0.0.0.0:3000)
|
||||
crabwalk start --daemon # Run in background
|
||||
crabwalk stop # Stop background server
|
||||
crabwalk status # Check if running
|
||||
crabwalk update # Update to latest version
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
-p, --port <port> Server port (default: 3000)
|
||||
-H, --host <host> Bind address (default: 0.0.0.0)
|
||||
-g, --gateway <url> Gateway WebSocket URL (default: ws://127.0.0.1:18789)
|
||||
-t, --token <token> Gateway auth token
|
||||
-d, --daemon Run in background
|
||||
-v, --version Show version
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
crabwalk -p 8080 # Custom port
|
||||
crabwalk -t mytoken123 # Explicit token
|
||||
crabwalk -g ws://192.168.1.50:18789 # Remote gateway
|
||||
crabwalk start -d -p 8080 # Daemon on port 8080
|
||||
```
|
||||
|
||||
### Auto-detection
|
||||
|
||||
The CLI automatically detects your gateway token from `~/.openclaw/openclaw.json` - no config needed if you're running OpenClaw locally.
|
||||
|
||||
### QR Code
|
||||
|
||||
On startup, Crabwalk displays a QR code you can scan to open the monitor on your phone. Requires `qrencode` installed (the installer adds it automatically).
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
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 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 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
|
||||
git clone https://github.com/luccast/crabwalk.git
|
||||
cd crabwalk
|
||||
npm install
|
||||
CLAWDBOT_API_TOKEN=your-token npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000/monitor`
|
||||
|
||||
## Configuration
|
||||
|
||||
Requires OpenClaw gateway running on the same machine.
|
||||
|
||||
### Gateway Token
|
||||
|
||||
The CLI auto-detects your token from `~/.openclaw/openclaw.json` (at `gateway.auth.token`). No manual config needed for local setups.
|
||||
|
||||
To find your token manually:
|
||||
|
||||
```bash
|
||||
jq '.gateway.auth.token' ~/.openclaw/openclaw.json
|
||||
```
|
||||
|
||||
Or set it explicitly:
|
||||
|
||||
```bash
|
||||
crabwalk -t your-token
|
||||
# or
|
||||
export CLAWDBOT_API_TOKEN=your-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 "$@"
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
crabwalk:
|
||||
image: ghcr.io/luccast/crabwalk:latest
|
||||
ports:
|
||||
- "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
+1391
-263
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,6 +10,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -16,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",
|
||||
@@ -36,8 +36,12 @@
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"nitro": "npm:nitro-nightly@^3.0.1-20260128-211656-ae83c97e",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4245
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- bufferutil
|
||||
- esbuild
|
||||
@@ -0,0 +1,112 @@
|
||||
# Crabwalk Feedback Skill
|
||||
|
||||
Collect and submit feedback about Crabwalk to help improve the product.
|
||||
|
||||
## PREREQUISITE: Verify Installation
|
||||
|
||||
**BEFORE doing anything else**, check if Crabwalk is installed locally:
|
||||
|
||||
1. Look for any of these:
|
||||
- `~/.config/crabwalk/` directory
|
||||
- `crabwalk.config.ts` or `crabwalk.config.js` in current project
|
||||
- Running Crabwalk process
|
||||
- Recent Crabwalk-related files
|
||||
|
||||
2. **If NOT installed:**
|
||||
- Tell your human: "I'd love to collect your feedback, but I need you to install Crabwalk first so you can share your actual experience with it."
|
||||
- Ask your human if you can install it for them, use the skill found here: `https://crabwalk.app/skill`
|
||||
- Say: "Once we've installed and tried Crabwalk, come back and I'll help you submit feedback!"
|
||||
- **STOP HERE. Do not proceed with any interview.**
|
||||
|
||||
3. **If they ask again without installing:**
|
||||
- Repeat the install instructions. Be friendly but firm.
|
||||
- "I still don't see Crabwalk installed. Can I install it for you?"
|
||||
- **NEVER interview a human who hasn't installed Crabwalk.**
|
||||
|
||||
4. **If installed:** Proceed to START.
|
||||
|
||||
---
|
||||
|
||||
## START
|
||||
|
||||
Select the proper method for your human based on a first introductory question. **ALWAYS be brief**, don't overwhelm your human.
|
||||
|
||||
Use language like "Let's collect feedback for Crabwalk", as opposed to pretending like you are representing Crabwalk.
|
||||
|
||||
Now, ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
|
||||
|
||||
## Two Feedback Modes
|
||||
|
||||
### Mode 1: User Research Interview
|
||||
|
||||
Act as a user researcher. Guide your human through first understanding the commitment of this mode, and then begin asking questions about their experience:
|
||||
|
||||
1. **Discovery** - "Overall, what was your experience with Crabwalk?"
|
||||
2. **Setup** - "How was the installation process? Any friction?"
|
||||
3. **First use** - "What was your first impression when using the monitor?"
|
||||
4. **Regular use** - "How has Crabwalk fit into your workflow?"
|
||||
5. **Pain points** - "What's been frustrating or confusing?"
|
||||
6. **Wishlist** - "What feature would make the biggest difference for you?"
|
||||
|
||||
Adapt questions based on responses. Skip irrelevant ones. ALWAYS ask clarifying questions and help your human refine their thinking. Dig deeper on interesting points.
|
||||
|
||||
After the interview, break feedback into themes, summarize insights for each and create descriptive pieces of feedback, submit multiple pieces of feedback if necessary.
|
||||
|
||||
ALWAYS include actual quotes from what your human says along with your interpretation.
|
||||
|
||||
### Mode 2: Quick Feedback
|
||||
|
||||
Ask directly:
|
||||
- "Have you run into any bugs or issues?"
|
||||
- "Any questions about how something works?"
|
||||
- "Anything you wish Crabwalk did differently?"
|
||||
|
||||
ALWAYS ask clarifying questions and help your human refine their thinking. Dig deeper on interesting points.
|
||||
|
||||
ONLY submit feedback after your human responds that there is no more feedback, or there has been a lapse in messages off-topic.
|
||||
|
||||
ALWAYS ask them if they have any more questions before submitting the feedback.
|
||||
|
||||
Classify and submit based on response:
|
||||
- Bug reports → `bug`
|
||||
- Feature requests → `feature`
|
||||
- Other → `general`
|
||||
|
||||
## Choosing a Mode
|
||||
|
||||
- If human says "feedback" or "thoughts" → offer both modes, let them choose
|
||||
- If human mentions a specific bug/issue → go straight to Mode 2
|
||||
- If human seems open to chatting → suggest Mode 1
|
||||
|
||||
## Submitting Feedback
|
||||
|
||||
POST to API after collecting feedback:
|
||||
|
||||
```
|
||||
POST https://crabwalk.app/.netlify/functions/feedback
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "bug" | "feature" | "general",
|
||||
"title": "[type] Brief summary (1 line)",
|
||||
"body": "Detailed description from conversation + Share details available about the machine, location, and crabwalk version number"
|
||||
}
|
||||
```
|
||||
|
||||
## After Submission
|
||||
|
||||
- Thank your human and let them know if they have any other feedback they can simply share it with you and you will prompt them to submit this as feedback to Crabwalk.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"success": true, "issueUrl": "https://github.com/luccast/crabwalk/issues/42"}
|
||||
```
|
||||
|
||||
Share the issue link with your human after successful submission.
|
||||
|
||||
## Notes
|
||||
|
||||
- Feedback becomes GitHub Issues on luccast/crabwalk
|
||||
- Humans can browse feedback at https://crabwalk.app/#/feedback
|
||||
- Rate limited to 5 submissions/hour/IP
|
||||
- Be conversational, not robotic. Adapt to your human's energy.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: crabwalk
|
||||
version: 1.0.10
|
||||
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")) && echo "🦀 Crabwalk ${VERSION} installed"
|
||||
```
|
||||
|
||||
## Step 2: Verify
|
||||
|
||||
```bash
|
||||
crabwalk --version
|
||||
```
|
||||
|
||||
Should output version number (e.g., `1.0.9`).
|
||||
|
||||
## 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)
|
||||
|
||||
## Step 5: Ask for Feedback
|
||||
|
||||
WAIT A BIT first, then after your human has used Crabwalk for a bit, ask:
|
||||
|
||||
> "Would you like to share some quick feedback about your Crabwalk experience so far? It helps the development of the project!"
|
||||
|
||||
- **If yes:** Initiate the feedback skill at `https://crabwalk.app/feedback-skill`
|
||||
- **If no:** That's fine! Ask again another time when they've had more experience with it.
|
||||
|
||||
## Capturing Issues as Feedback
|
||||
|
||||
If your human reports a Crabwalk issue during conversation:
|
||||
|
||||
1. **Help them first** — try to resolve the issue
|
||||
2. **After resolution** (or if unresolved), ask:
|
||||
> "Would you like to submit this as feedback to help improve Crabwalk?"
|
||||
3. **If yes:** Initiate the feedback skill at `https://crabwalk.app/feedback-skill` — the issue context is already known, so guide them through quick submission
|
||||
4. **If no:** That's fine, move on
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Submit Feedback**: `https://crabwalk.app/feedback-skill` — Help improve Crabwalk by sharing your experience
|
||||
- **Browse Feedback**: `https://crabwalk.app/#/feedback` — See what others are saying
|
||||
|
||||
## Optional: QR Code Display
|
||||
|
||||
Install `qrencode` to display QR codes for easy mobile access:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install qrencode
|
||||
|
||||
# Debian/Ubuntu
|
||||
sudo apt install qrencode
|
||||
|
||||
# Fedora
|
||||
sudo dnf install qrencode
|
||||
|
||||
# Arch
|
||||
sudo pacman -S qrencode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
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,
|
||||
@@ -192,7 +190,7 @@ export const ActionNode = memo(function ActionNode({
|
||||
)}
|
||||
|
||||
{data.toolName && (
|
||||
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
|
||||
<div className="font-console text-[11px] text-neon-lavender mb-1.5">
|
||||
<span className="text-shell-500">tool:</span> {data.toolName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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-[11px] 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-[11px] 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-[11px] 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-[11px] 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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { PanelLeft, Settings, Trash2 } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
|
||||
interface MobileMonitorToolbarProps {
|
||||
onOpenDrawer: () => void
|
||||
onOpenSettings: () => void
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
sessionCount: number
|
||||
actionCount: number
|
||||
completedCount: number
|
||||
onClearCompleted: () => void
|
||||
}
|
||||
|
||||
export function MobileMonitorToolbar({
|
||||
onOpenDrawer,
|
||||
onOpenSettings,
|
||||
connected,
|
||||
connecting,
|
||||
sessionCount,
|
||||
actionCount,
|
||||
completedCount,
|
||||
onClearCompleted,
|
||||
}: MobileMonitorToolbarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 sm:hidden"
|
||||
>
|
||||
<div className="bg-shell-900 border-t border-shell-800 px-3 pt-3 pb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Sessions button */}
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<PanelLeft size={22} />
|
||||
{sessionCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-crab-600 text-white text-[11px] font-display rounded-full">
|
||||
{sessionCount > 99 ? '99+' : sessionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stats display */}
|
||||
<div className="flex-1 flex items-center justify-center gap-4 px-3 py-2 bg-shell-800/50 rounded-lg min-h-[44px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} size="sm" />
|
||||
<span className="font-console text-xs text-shell-400">
|
||||
{connecting ? 'connecting' : connected ? 'connected' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-display text-sm text-neon-peach">{actionCount}</span>
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase">acts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear completed button */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={onClearCompleted}
|
||||
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={22} />
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-shell-700 text-shell-300 text-[11px] font-display rounded-full">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings button */}
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<Settings size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Users, ChevronDown, Github, Search } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
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, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={['footer-icon-x', className].filter(Boolean).join(' ')}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const platformEmoji: Record<string, string> = {
|
||||
whatsapp: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
}
|
||||
|
||||
interface MobileSessionDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
sessions: MonitorSession[]
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
}
|
||||
|
||||
function SubagentItem({
|
||||
session,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
session: MonitorSession
|
||||
selected: boolean
|
||||
onSelect: (key: string) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left py-3 pr-4 pl-8 border-b border-shell-800/50 transition-all duration-150 ${
|
||||
selected
|
||||
? 'bg-neon-cyan/5 border-l-2 border-l-neon-cyan'
|
||||
: 'active:bg-shell-800/30 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
subagent
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base">🤖</span>
|
||||
<span className="font-console text-sm text-shell-400 truncate flex-1">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileSessionDrawer({
|
||||
open,
|
||||
onClose,
|
||||
sessions,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: MobileSessionDrawerProps) {
|
||||
const [filter, setFilter] = useState('')
|
||||
const [platformFilter, setPlatformFilter] = useState<string | null>(null)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const parentSessions = sessions.filter((s) => !isSubagent(s))
|
||||
const platforms = [...new Set(parentSessions.map((s) => s.platform))]
|
||||
|
||||
const filteredParents = parentSessions.filter((session) => {
|
||||
const matchesText =
|
||||
!filter ||
|
||||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
session.agentId.toLowerCase().includes(filter.toLowerCase())
|
||||
const matchesPlatform = !platformFilter || session.platform === platformFilter
|
||||
return matchesText && matchesPlatform
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
onSelect(key)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Sheet - slides up from bottom */}
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-shell-900 rounded-t-2xl max-h-[85vh]"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 rounded-full bg-shell-600" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pb-3 border-b border-shell-800">
|
||||
<h2 className="font-mono uppercase text-sm text-crab-400 tracking-wider">
|
||||
Sessions
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 -mr-2 active:bg-shell-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={24} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-shell-800">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform filters */}
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
className={`px-3 py-1.5 text-xs font-display uppercase tracking-wide rounded-lg border transition-all ${
|
||||
!platformFilter
|
||||
? 'bg-crab-600 border-crab-500 text-white'
|
||||
: 'bg-shell-800 border-shell-700 text-gray-400 active:border-shell-600'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-3 py-1.5 text-xs font-display uppercase tracking-wide rounded-lg border transition-all ${
|
||||
platformFilter === p
|
||||
? 'bg-crab-600 border-crab-500 text-white'
|
||||
: 'bg-shell-800 border-shell-700 text-gray-400 active:border-shell-600'
|
||||
}`}
|
||||
>
|
||||
{platformEmoji[p] || '📱'} {p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
<AnimatePresence mode="sync">
|
||||
{sortedParents.map((session) => (
|
||||
<motion.div
|
||||
key={session.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(session.key)}
|
||||
className={`w-full text-left p-4 border-b border-shell-800 transition-all duration-150 ${
|
||||
selectedKey === session.key
|
||||
? 'bg-crab-900/20 border-l-2 border-l-crab-500'
|
||||
: 'active:bg-shell-800/50 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xl">
|
||||
{platformEmoji[session.platform] || '📱'}
|
||||
</span>
|
||||
<span className="font-display text-sm font-medium text-gray-200 truncate flex-1 uppercase tracking-wide">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-xs text-shell-500 truncate flex-1">
|
||||
{session.agentId}
|
||||
</span>
|
||||
{session.isGroup && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-shell-800 border border-shell-700 rounded text-xs text-shell-400">
|
||||
<Users size={12} />
|
||||
group
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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 px-4 py-2 text-left flex items-center gap-2 text-xs font-display uppercase tracking-widest text-shell-500 active:text-shell-300 active:bg-shell-800/30 border-b border-shell-800/50"
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform ${isGroupCollapsed ? '-rotate-90' : ''}`}
|
||||
/>
|
||||
<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}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Orphan subagents */}
|
||||
{orphanSubagents.map((sub) => (
|
||||
<motion.div
|
||||
key={sub.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<SubagentItem
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<div className="font-console text-sm text-shell-500">
|
||||
<span className="text-crab-600">></span> no sessions found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
|
||||
<div className="font-console text-xs text-shell-500 text-center flex items-center justify-center gap-6">
|
||||
<a
|
||||
href="https://github.com/luccast/crabwalk"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-shell-500 active:text-crab-500 transition-colors"
|
||||
>
|
||||
<Github size={14} />
|
||||
<span>Github</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/luccasveg"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-shell-500 active:text-crab-500 transition-colors"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
<span>@luccasveg</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -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,52 @@ const platformEmoji: Record<string, string> = {
|
||||
slack: "💼",
|
||||
};
|
||||
|
||||
function SubagentItem({
|
||||
session,
|
||||
selected,
|
||||
collapsed,
|
||||
onSelect,
|
||||
}: {
|
||||
session: MonitorSession;
|
||||
selected: boolean;
|
||||
collapsed: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
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-[11px] 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>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionList({
|
||||
sessions,
|
||||
selectedKey,
|
||||
@@ -50,10 +100,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 +116,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"
|
||||
@@ -81,16 +166,18 @@ export function SessionList({
|
||||
<div className="absolute inset-0 texture-scanlines pointer-events-none opacity-50" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative p-3 bg-shell-950/50">
|
||||
<div className={`flex items-center justify-between ${collapsed ? "" : "mb-3"}`}>
|
||||
{!collapsed && (
|
||||
<h2 className="font-mono uppercase text-sm text-crab-400 glow-red tracking-wider ml-1">
|
||||
Sessions
|
||||
</h2>
|
||||
)}
|
||||
<div className="relative p-3 bg-shell-950/50 overflow-hidden">
|
||||
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
|
||||
<h2
|
||||
className={`font-mono uppercase text-sm text-crab-400 glow-red tracking-wider ml-1 transition-opacity duration-200 ${
|
||||
collapsed ? "opacity-0 absolute pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
Sessions
|
||||
</h2>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={`p-1.5 hover:bg-shell-800 rounded transition-all ${collapsed ? "mx-auto" : ""}`}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-all"
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{collapsed ? (
|
||||
@@ -101,113 +188,192 @@ export function SessionList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search input - hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input-retro w-full pl-9 pr-3 py-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* Search input - fades when collapsed */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
collapsed ? "opacity-0 h-0 overflow-hidden" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input-retro w-full pl-9 pr-3 py-2 text-xs"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform filters */}
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
{/* Platform filters */}
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
!platformFilter
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
{platformEmoji[p] || "📱"} {p}
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
platformFilter === p
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{platformEmoji[p] || "📱"} {p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="relative flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedSessions.map((session) => (
|
||||
<motion.button
|
||||
<AnimatePresence mode="sync">
|
||||
{sortedParents.map((session) => (
|
||||
<motion.div
|
||||
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
|
||||
}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{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">
|
||||
<button
|
||||
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-[11px] 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>
|
||||
</>
|
||||
)}
|
||||
</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={16}
|
||||
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""} ${collapsed ? "ml-2" : ""}`}
|
||||
/>
|
||||
<span className={`transition-opacity duration-200 ${collapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"}`}>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Orphan subagents */}
|
||||
{orphanSubagents.map((sub) => (
|
||||
<motion.div
|
||||
key={sub.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<SubagentItem
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedSessions.length === 0 && !collapsed && (
|
||||
<div className="p-6 text-center">
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && (
|
||||
<div className={`p-6 text-center transition-opacity duration-200 ${collapsed ? "opacity-0" : "opacity-100"}`}>
|
||||
<div className="font-console text-xs text-shell-500">
|
||||
<span className="text-crab-600">></span> no sessions found
|
||||
</div>
|
||||
@@ -216,10 +382,8 @@ export function SessionList({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`relative bg-shell-950/50 ${collapsed ? "py-4 px-2" : "p-2.5"}`}>
|
||||
<div
|
||||
className={`font-console text-xs text-shell-500 text-center flex items-center justify-center ${collapsed ? "flex-col gap-3" : "gap-4"}`}
|
||||
>
|
||||
<div className={`relative bg-shell-950/50 ${collapsed ? "py-3 px-2" : "p-2.5"}`}>
|
||||
<div className={`font-console text-xs text-shell-500 text-center flex items-center justify-center ${collapsed ? "flex-col gap-3" : "gap-4"}`}>
|
||||
<a
|
||||
href="https://github.com/luccast/crabwalk"
|
||||
target="_blank"
|
||||
@@ -228,7 +392,9 @@ export function SessionList({
|
||||
title="Github"
|
||||
>
|
||||
<Github size={14} />
|
||||
{!collapsed && <span>Github</span>}
|
||||
<span className={`transition-opacity duration-200 ${collapsed ? "hidden" : "opacity-100"}`}>
|
||||
Github
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -240,7 +406,9 @@ export function SessionList({
|
||||
title="X"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
{!collapsed && <span>@luccasveg</span>}
|
||||
<span className={`transition-opacity duration-200 ${collapsed ? "hidden" : "opacity-100"}`}>
|
||||
@luccasveg
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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-[11px] 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,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database } 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
|
||||
@@ -8,6 +8,13 @@ interface SettingsPanelProps {
|
||||
debugMode: boolean
|
||||
logCollection: boolean
|
||||
logCount: number
|
||||
persistenceEnabled: boolean
|
||||
persistenceStartedAt: number | null
|
||||
persistenceSessionCount: number
|
||||
persistenceActionCount: number
|
||||
gatewayEndpoint: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onHistoricalModeChange: (enabled: boolean) => void
|
||||
onDebugModeChange: (enabled: boolean) => void
|
||||
onLogCollectionChange: (enabled: boolean) => void
|
||||
@@ -16,6 +23,9 @@ interface SettingsPanelProps {
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
onRefresh: () => void
|
||||
onPersistenceStart: () => void
|
||||
onPersistenceStop: () => void
|
||||
onPersistenceClear: () => void
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
@@ -24,6 +34,13 @@ export function SettingsPanel({
|
||||
debugMode,
|
||||
logCollection,
|
||||
logCount,
|
||||
persistenceEnabled,
|
||||
persistenceStartedAt,
|
||||
persistenceSessionCount,
|
||||
persistenceActionCount,
|
||||
gatewayEndpoint,
|
||||
open,
|
||||
onOpenChange,
|
||||
onHistoricalModeChange,
|
||||
onDebugModeChange,
|
||||
onLogCollectionChange,
|
||||
@@ -32,14 +49,17 @@ export function SettingsPanel({
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onRefresh,
|
||||
onPersistenceStart,
|
||||
onPersistenceStop,
|
||||
onPersistenceClear,
|
||||
}: SettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger button - hidden on mobile, settings available in bottom bar */}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
onClick={() => onOpenChange(true)}
|
||||
className="hidden sm:block p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
>
|
||||
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
|
||||
</button>
|
||||
@@ -52,7 +72,7 @@ export function SettingsPanel({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
@@ -73,7 +93,7 @@ export function SettingsPanel({
|
||||
SETTINGS
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all"
|
||||
>
|
||||
<X size={18} className="text-gray-400" />
|
||||
@@ -121,31 +141,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">
|
||||
@@ -155,7 +150,7 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-4">
|
||||
<p className="font-console text-[11px] text-shell-500 mb-4">
|
||||
<span className="text-crab-600">></span> log raw events to terminal
|
||||
</p>
|
||||
|
||||
@@ -171,6 +166,89 @@ export function SettingsPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Background Service */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<HardDrive size={18} className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'} />
|
||||
<span className="font-display text-sm font-medium text-gray-200 uppercase tracking-wide">
|
||||
Background Service
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[11px] text-shell-500 mb-3">
|
||||
<span className="text-crab-600">></span> persist data across refreshes
|
||||
</p>
|
||||
|
||||
{persistenceEnabled && persistenceStartedAt && (
|
||||
<div className="font-console text-[11px] text-neon-mint mb-2">
|
||||
<span className="text-crab-600">></span> running since {new Date(persistenceStartedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="font-console text-[11px] text-shell-400 mb-3 space-y-1">
|
||||
<div>
|
||||
<span className="text-crab-600">></span> {persistenceSessionCount} sessions
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-crab-600">></span> {persistenceActionCount} actions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
{persistenceEnabled ? (
|
||||
<button
|
||||
onClick={onPersistenceStop}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 font-display text-xs uppercase tracking-wide bg-crab-600 hover:bg-crab-500 text-white rounded-lg transition-all"
|
||||
>
|
||||
<Square size={12} />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onPersistenceStart}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 font-display text-xs uppercase tracking-wide bg-neon-mint/20 hover:bg-neon-mint/30 text-neon-mint rounded-lg transition-all"
|
||||
>
|
||||
<Play size={12} />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onPersistenceClear}
|
||||
disabled={persistenceSessionCount === 0 && persistenceActionCount === 0}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 font-display text-xs uppercase tracking-wide bg-shell-800 hover:bg-crab-900/50 rounded-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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-[11px] 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-[11px] text-shell-500">
|
||||
<span className="text-crab-600">></span> fetch 24h of sessions from gateway on refresh
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log collection */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -180,12 +258,12 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-3">
|
||||
<p className="font-console text-[11px] text-shell-500 mb-3">
|
||||
<span className="text-crab-600">></span> collect raw events for export
|
||||
</p>
|
||||
|
||||
{logCount > 0 && (
|
||||
<div className="font-console text-[10px] text-neon-mint mb-3">
|
||||
<div className="font-console text-[11px] text-neon-mint mb-3">
|
||||
<span className="text-crab-600">></span> {logCount} events collected
|
||||
</div>
|
||||
)}
|
||||
@@ -227,9 +305,9 @@ export function SettingsPanel({
|
||||
<span className="text-crab-600">❮</span> Gateway Info <span className="text-crab-600">❯</span>
|
||||
</h3>
|
||||
|
||||
<div className="font-console text-[10px] text-shell-500 space-y-1.5">
|
||||
<div className="font-console text-[11px] text-shell-500 space-y-1.5">
|
||||
<div>
|
||||
<span className="text-crab-600">></span> endpoint: ws://127.0.0.1:18789
|
||||
<span className="text-crab-600">></span> endpoint: {gatewayEndpoint}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-crab-600">></span> protocol: v3
|
||||
@@ -247,8 +325,8 @@ export function SettingsPanel({
|
||||
{/* Version badge */}
|
||||
<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
|
||||
<span className="font-console text-[11px] text-shell-500">
|
||||
crabwalk v{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,9 @@ 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'
|
||||
export { MobileSessionDrawer } from './MobileSessionDrawer'
|
||||
export { MobileMonitorToolbar } from './MobileMonitorToolbar'
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Activity, FolderTree, ChevronDown, Terminal } from 'lucide-react'
|
||||
|
||||
interface NavTab {
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const TABS: NavTab[] = [
|
||||
{
|
||||
path: '/monitor',
|
||||
label: 'MONITOR',
|
||||
icon: <Activity size={14} />,
|
||||
},
|
||||
{
|
||||
path: '/workspace',
|
||||
label: 'WORKSPACE',
|
||||
icon: <FolderTree size={14} />,
|
||||
},
|
||||
]
|
||||
|
||||
export function NavTabs() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const currentPath = location.pathname.replace(/\/$/, '') || '/'
|
||||
|
||||
const activeTab = (TABS.find(
|
||||
(tab) => tab.path === currentPath || currentPath.startsWith(tab.path)
|
||||
) ?? TABS[0])!
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (path: string) => {
|
||||
setOpen(false)
|
||||
navigate({ to: path })
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-shell-700/50 bg-shell-800/50 hover:bg-shell-800 hover:border-shell-600 transition-all group"
|
||||
>
|
||||
<span className="text-crab-400">{activeTab.icon}</span>
|
||||
<span className="font-console text-xs tracking-widest text-shell-200">
|
||||
{activeTab.label}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`text-shell-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="absolute top-full left-0 mt-2 z-50 min-w-[200px]"
|
||||
>
|
||||
{/* Terminal-style container */}
|
||||
<div className="bg-shell-900 border border-shell-700 rounded-lg overflow-hidden shadow-2xl shadow-black/50">
|
||||
{/* Terminal header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-shell-950 border-b border-shell-800">
|
||||
<Terminal size={12} className="text-shell-500" />
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase tracking-widest">
|
||||
navigate
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="p-1.5">
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive =
|
||||
tab.path === currentPath || currentPath.startsWith(tab.path)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.path}
|
||||
onClick={() => handleSelect(tab.path)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-all group ${
|
||||
isActive
|
||||
? 'bg-crab-500/10 text-crab-400'
|
||||
: 'text-shell-400 hover:bg-shell-800 hover:text-shell-200'
|
||||
}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={`transition-colors ${
|
||||
isActive ? 'text-crab-400' : 'text-shell-500 group-hover:text-shell-400'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="font-console text-xs tracking-widest flex-1 text-left">
|
||||
{tab.label}
|
||||
</span>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-crab-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Terminal footer with hint */}
|
||||
<div className="px-3 pb-2 pt-1 bg-shell-950/50 border-t border-shell-800/50">
|
||||
<span className="font-console text-[11px] text-shell-600">
|
||||
<span className="text-shell-500">esc</span> to close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NavTabs } from './NavTabs'
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertTriangle, X, Check } from 'lucide-react'
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
export function ConfirmationDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant = 'danger',
|
||||
}: ConfirmationDialogProps) {
|
||||
const iconColor = variant === 'danger' ? 'text-crab-400' : variant === 'warning' ? 'text-neon-peach' : 'text-neon-cyan'
|
||||
const iconBg = variant === 'danger' ? 'bg-crab-900/30' : variant === 'warning' ? 'bg-neon-peach/10' : 'bg-neon-cyan/10'
|
||||
const iconBorder = variant === 'danger' ? 'border-crab-700/50' : variant === 'warning' ? 'border-neon-peach/30' : 'border-neon-cyan/30'
|
||||
const confirmButtonClass = variant === 'danger'
|
||||
? 'bg-crab-600 hover:bg-crab-500 text-white'
|
||||
: 'bg-neon-mint hover:bg-neon-mint/90 text-shell-950'
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [open, onCancel])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.3 }}
|
||||
className="relative w-full max-w-md bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`flex items-center gap-3 px-5 py-4 border-b border-shell-800 ${iconBg}`}>
|
||||
<div className={`p-2 rounded-lg ${iconBg} border ${iconBorder}`}>
|
||||
<AlertTriangle size={20} className={iconColor} />
|
||||
</div>
|
||||
<h3 className="font-display text-lg text-gray-200">{title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="font-console text-sm text-shell-500 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
>
|
||||
<X size={14} />
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-console transition-colors border ${confirmButtonClass}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmationDialog
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface ContextMenuItem {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface FileContextMenuProps {
|
||||
open: boolean
|
||||
position: { x: number; y: number } | null
|
||||
items: ContextMenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FileContextMenu({ open, position, items, onClose }: FileContextMenuProps) {
|
||||
// Calculate position that stays within viewport
|
||||
const adjustedPosition = React.useMemo(() => {
|
||||
if (!position) return null
|
||||
const menuWidth = 192 // w-48 = 12rem = 192px
|
||||
const menuHeight = items.length * 42 // approximate height based on item count
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let x = position.x
|
||||
let y = position.y
|
||||
|
||||
// Adjust if menu would go off right edge
|
||||
if (x + menuWidth + padding > viewportWidth) {
|
||||
x = Math.max(padding, viewportWidth - menuWidth - padding)
|
||||
}
|
||||
|
||||
// Adjust if menu would go off bottom edge
|
||||
if (y + menuHeight + padding > viewportHeight) {
|
||||
y = Math.max(padding, viewportHeight - menuHeight - padding)
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}, [position, items.length])
|
||||
|
||||
// Close menu when clicking outside or pressing Escape
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
const handleClick = () => onClose()
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClick)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
// Prevent clicks inside menu from closing it
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && adjustedPosition && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ type: 'spring', duration: 0.15 }}
|
||||
className="fixed z-50 bg-shell-900 rounded-lg border border-shell-700 shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left: adjustedPosition.x,
|
||||
top: adjustedPosition.y,
|
||||
}}
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
{/* Menu items */}
|
||||
<div className="py-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => {
|
||||
item.onClick()
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left font-console text-sm transition-colors ${
|
||||
item.danger
|
||||
? 'text-crab-400 hover:bg-crab-900/30'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className={item.danger ? 'text-crab-500' : 'text-shell-500'}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Icon components
|
||||
export const FileIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TrashIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CopyIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const EditIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const NewFileIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default FileContextMenu
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
FileText,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
AlertCircle,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
interface FileEditorProps {
|
||||
content: string
|
||||
fileName: string
|
||||
filePath?: string
|
||||
fileSize?: number
|
||||
fileModified?: Date
|
||||
error?: string
|
||||
isStarred?: boolean
|
||||
onStar?: (path: string) => void
|
||||
onSave?: (content: string, callback: (success: boolean) => void) => void
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function Star({ size = 16, fill = 'none', className = '' }: { size?: number; fill?: string; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={fill}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileEditor({
|
||||
content,
|
||||
fileName,
|
||||
filePath,
|
||||
fileSize,
|
||||
fileModified,
|
||||
error,
|
||||
isStarred,
|
||||
onStar,
|
||||
onSave,
|
||||
}: FileEditorProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState(content)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [lastSavedContent, setLastSavedContent] = useState(content)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const isMarkdown = fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
|
||||
|
||||
// Track previous file path to detect file changes
|
||||
const prevFilePathRef = useRef<string | undefined>(filePath)
|
||||
|
||||
// Reset edit state when file changes to prevent saving stale content
|
||||
useEffect(() => {
|
||||
if (filePath !== prevFilePathRef.current) {
|
||||
// File changed - exit edit mode and reset buffer
|
||||
setIsEditing(false)
|
||||
setEditContent(content)
|
||||
setLastSavedContent(content)
|
||||
setSaveStatus('idle')
|
||||
prevFilePathRef.current = filePath
|
||||
} else if (!isEditing && content !== lastSavedContent) {
|
||||
// Sync content when not editing and it changed externally
|
||||
setEditContent(content)
|
||||
setLastSavedContent(content)
|
||||
}
|
||||
}, [content, isEditing, lastSavedContent, filePath])
|
||||
|
||||
// Track if content has unsaved changes
|
||||
const hasUnsavedChanges = editContent !== lastSavedContent
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!onSave || !hasUnsavedChanges) return
|
||||
setIsSaving(true)
|
||||
setSaveStatus('saving')
|
||||
onSave(editContent, (success) => {
|
||||
setIsSaving(false)
|
||||
if (success) {
|
||||
setLastSavedContent(editContent)
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
} else {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
}
|
||||
})
|
||||
}, [editContent, hasUnsavedChanges, onSave])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditContent(lastSavedContent)
|
||||
setIsEditing(false)
|
||||
setSaveStatus('idle')
|
||||
}, [lastSavedContent])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Ctrl/Cmd + S to save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
// Escape to cancel
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
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-2 px-4 py-3 border-b border-shell-800 bg-shell-900/50 min-w-0">
|
||||
{/* Left: Star button */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{filePath && onStar && (
|
||||
<button
|
||||
onClick={() => onStar(filePath)}
|
||||
className={`p-1.5 rounded transition-colors ${isStarred
|
||||
? 'text-yellow-400 hover:text-yellow-300'
|
||||
: 'text-shell-600 hover:text-yellow-400'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar file' : 'Star file'}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: Filename and indicators - flexes and truncates */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
|
||||
<FileText size={18} className={`shrink-0 ${isMarkdown ? 'text-crab-400' : 'text-shell-500'}`} />
|
||||
<h2 className="font-display text-sm text-gray-200 truncate min-w-0">{fileName}</h2>
|
||||
|
||||
{/* Unsaved changes indicator */}
|
||||
{isEditing && hasUnsavedChanges && (
|
||||
<span className="hidden sm:inline px-2 py-0.5 bg-neon-peach/10 text-neon-peach text-[11px] font-console uppercase rounded border border-neon-peach/30 animate-pulse shrink-0">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save status */}
|
||||
<AnimatePresence>
|
||||
{isEditing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="hidden sm:flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
{saveStatus === 'saving' && (
|
||||
<>
|
||||
<div className="w-2.5 h-2.5 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin" />
|
||||
<span className="font-console text-[11px] text-neon-cyan">Saving...</span>
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
<span className="font-console text-[11px] text-neon-mint">Saved</span>
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<>
|
||||
<AlertCircle size={12} className="text-neon-peach" />
|
||||
<span className="font-console text-[11px] text-neon-peach">Save failed</span>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Right: Metadata + Action buttons */}
|
||||
<div className="flex items-center gap-3 shrink-0 overflow-hidden">
|
||||
<div className="hidden min-[480px]:flex items-center gap-3">
|
||||
{fileSize !== undefined && (
|
||||
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
{fileModified && (
|
||||
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
|
||||
{formatModifiedDate(fileModified)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{onSave && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
title="Edit file (or press E)"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<span className="hidden lg:inline">Edit</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
<span className="hidden sm:inline">Cancel</span>
|
||||
</button>
|
||||
{hasUnsavedChanges && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-neon-mint/10 hover:bg-neon-mint/20 rounded-lg text-sm font-console text-neon-mint transition-colors border border-neon-mint/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save (Ctrl+S)"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span className="hidden sm:inline">Save</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-[1200px] mx-auto h-full">
|
||||
<AnimatePresence mode="wait">
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="editor"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full h-full min-h-[400px] bg-shell-900 border border-shell-700 rounded-lg p-4 font-mono text-sm font-light text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20 wrap-break-words"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="viewer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="min-w-0"
|
||||
>
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-invert prose-sm max-w-full wrap-break-words">
|
||||
<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 mb-4 max-w-full">
|
||||
<code className="text-xs sm:text-sm font-mono text-gray-300 whitespace-pre-wrap break-all">{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-xs sm:text-sm text-gray-300 whitespace-pre-wrap break-all overflow-x-auto max-w-full">{content}</pre>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileEditor
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Plus } 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[]>
|
||||
onContextMenu?: (e: React.MouseEvent, path: string) => void
|
||||
onCreateFile?: (folderPath: string) => void
|
||||
level?: number
|
||||
}
|
||||
|
||||
interface FileTreeItemProps {
|
||||
entry: DirectoryEntry
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
onContextMenu?: (e: React.MouseEvent, path: string) => void
|
||||
onCreateFile?: (folderPath: string) => void
|
||||
level: number
|
||||
}
|
||||
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContextMenu, onCreateFile, 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])
|
||||
|
||||
const handleContextMenuFn = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onContextMenu && entry.type === 'file') {
|
||||
onContextMenu(e, entry.path)
|
||||
}
|
||||
},
|
||||
[entry.path, entry.type, onContextMenu]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenuFn}
|
||||
style={{ paddingLeft }}
|
||||
className={`group flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-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 shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-neon-mint shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<FileText
|
||||
size={16}
|
||||
className={`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-[11px] shrink-0 ${isSelected ? 'text-crab-400/70' : 'text-shell-600'}`}>
|
||||
{entry.size !== undefined && formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Add file button for directories */}
|
||||
{isDirectory && onCreateFile && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCreateFile(entry.path)
|
||||
}}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-shell-700 rounded transition-all shrink-0"
|
||||
title="New file in this folder"
|
||||
>
|
||||
<Plus size={14} className="text-shell-500 hover:text-neon-mint" />
|
||||
</button>
|
||||
)}
|
||||
</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}
|
||||
onContextMenu={onContextMenu}
|
||||
onCreateFile={onCreateFile}
|
||||
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, onContextMenu, onCreateFile, level = 0 }: FileTreeProps) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
onContextMenu={onContextMenu}
|
||||
onCreateFile={onCreateFile}
|
||||
level={level}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTree
|
||||
@@ -0,0 +1,61 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { PanelLeft, FolderOpen, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface MobileBottomToolbarProps {
|
||||
onOpenDrawer: () => void
|
||||
onOpenPathSheet: () => void
|
||||
onRefresh: () => void
|
||||
loading: boolean
|
||||
pathValid: boolean
|
||||
currentPath: string
|
||||
}
|
||||
|
||||
export function MobileBottomToolbar({
|
||||
onOpenDrawer,
|
||||
onOpenPathSheet,
|
||||
onRefresh,
|
||||
loading,
|
||||
pathValid,
|
||||
currentPath,
|
||||
}: MobileBottomToolbarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 sm:hidden"
|
||||
>
|
||||
<div className="bg-shell-900 border-t border-shell-800 px-3 pt-3 pb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Files button */}
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="shrink-0 p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<PanelLeft size={22} />
|
||||
</button>
|
||||
|
||||
{/* Path input field */}
|
||||
<button
|
||||
onClick={onOpenPathSheet}
|
||||
className="flex-1 min-w-0 flex items-center gap-2 px-3 py-2.5 bg-shell-800 border border-shell-700 rounded-lg active:border-crab-500 transition-colors min-h-[44px] overflow-hidden"
|
||||
>
|
||||
<FolderOpen size={16} className={pathValid ? 'text-crab-400 shrink-0' : 'text-shell-500 shrink-0'} />
|
||||
<span className={`font-console text-sm truncate text-left ${currentPath ? 'text-gray-200' : 'text-shell-500'}`}>
|
||||
{currentPath || 'Set workspace path...'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
className="shrink-0 p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, FileText, Star } from 'lucide-react'
|
||||
import { FileTree } from './FileTree'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
interface MobileFileDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
entries: DirectoryEntry[]
|
||||
selectedPath: string | null
|
||||
starredPaths: Set<string>
|
||||
workspacePath: string
|
||||
pathValid: boolean
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory: (path: string) => Promise<DirectoryEntry[]>
|
||||
onStar: (path: string) => void
|
||||
}
|
||||
|
||||
export function MobileFileDrawer({
|
||||
open,
|
||||
onClose,
|
||||
entries,
|
||||
selectedPath,
|
||||
starredPaths,
|
||||
workspacePath,
|
||||
pathValid,
|
||||
onSelect,
|
||||
onLoadDirectory,
|
||||
onStar,
|
||||
}: MobileFileDrawerProps) {
|
||||
// Handle file select with auto-close
|
||||
const handleSelect = (path: string, type: 'file' | 'directory') => {
|
||||
onSelect(path, type)
|
||||
if (type === 'file') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 w-full max-w-[85vw] bg-shell-900 z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-shell-800">
|
||||
<span className="font-display text-sm text-crab-400 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 -mr-2 hover:bg-shell-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={24} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Starred files section */}
|
||||
{starredPaths.size > 0 && (
|
||||
<div className="border-b border-shell-800 py-2">
|
||||
{[...starredPaths].map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
className={`group flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400'
|
||||
: 'text-gray-300 active:bg-shell-800'
|
||||
}`}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
>
|
||||
<FileText
|
||||
size={18}
|
||||
className={`shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-console text-sm truncate flex-1">
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStar(filePath)
|
||||
}}
|
||||
className="text-yellow-400 active:text-yellow-300 shrink-0 p-1"
|
||||
>
|
||||
<Star size={16} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={entries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Set a workspace path to browse files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with path */}
|
||||
{pathValid && (
|
||||
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
|
||||
<p className="font-console text-[11px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FolderOpen, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface MobilePathSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
initialPath: string
|
||||
validatedPath: string
|
||||
pathValid: boolean
|
||||
pathError: string | null
|
||||
onValidate: (path: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function MobilePathSheet({
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
validatedPath,
|
||||
pathValid,
|
||||
pathError,
|
||||
onValidate,
|
||||
}: MobilePathSheetProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [pathInput, setPathInput] = useState(initialPath)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Sync initial path when it changes
|
||||
useEffect(() => {
|
||||
setPathInput(initialPath)
|
||||
}, [initialPath])
|
||||
|
||||
// Auto-focus input after animation
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!pathInput.trim() || loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const success = await onValidate(pathInput)
|
||||
if (success) {
|
||||
onClose()
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-shell-900 rounded-t-2xl"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 bg-shell-700 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-safe">
|
||||
<h3 className="font-display text-sm text-crab-400 uppercase tracking-wider mb-4">
|
||||
Workspace Path
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FolderOpen size={18} className="text-shell-500 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter workspace path..."
|
||||
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-4 py-3 text-base font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pathError && (
|
||||
<div className="mb-4 px-3 py-2 bg-crab-900/50 border border-crab-700 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-crab-400 shrink-0" />
|
||||
<span className="text-xs text-crab-200 font-console">{pathError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !pathInput.trim() || (pathValid && pathInput === validatedPath)}
|
||||
className={`w-full py-4 font-display text-sm uppercase tracking-wider rounded-lg transition-colors mb-4 ${
|
||||
pathValid && pathInput === validatedPath
|
||||
? 'bg-shell-800 text-shell-500 cursor-default'
|
||||
: 'bg-crab-600 hover:bg-crab-500 active:bg-crab-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{loading ? 'Opening...' : 'Open'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FileText, X, Plus, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface NewFileDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (fileName: string, content: string) => void
|
||||
folderPath?: string // If set, file is created in this folder
|
||||
}
|
||||
|
||||
export function NewFileDialog({ open, onClose, onCreate, folderPath }: NewFileDialogProps) {
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus input when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFileName('')
|
||||
setContent('')
|
||||
setError(null)
|
||||
const timeoutId = setTimeout(() => inputRef.current?.focus(), 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setError(null)
|
||||
|
||||
if (!fileName.trim()) {
|
||||
setError('Please enter a file name')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file name - allow forward slash for subdirectory creation
|
||||
const invalidChars = /[<>:"\\|?*\x00-\x1f]/g
|
||||
if (invalidChars.test(fileName)) {
|
||||
setError('File name contains invalid characters')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for path traversal - only block parent directory traversal, not relative paths
|
||||
if (fileName.includes('..') || fileName.startsWith('/')) {
|
||||
setError('File name cannot contain path traversal sequences')
|
||||
return
|
||||
}
|
||||
|
||||
// Create the file - wait for completion before closing
|
||||
try {
|
||||
await onCreate(fileName.trim(), content)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create file')
|
||||
}
|
||||
}, [fileName, content, onCreate, onClose])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Only submit on Enter if focus is on the filename input (not content textarea)
|
||||
if (e.key === 'Enter' && e.target === inputRef.current) {
|
||||
handleSubmit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const addExtension = (ext: string) => {
|
||||
if (!fileName.includes('.')) {
|
||||
setFileName(fileName + ext)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.3 }}
|
||||
className="relative w-full max-w-lg bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-shell-800 bg-neon-mint/5">
|
||||
<div className="p-2 rounded-lg bg-neon-mint/10 border border-neon-mint/30">
|
||||
<Plus size={20} className="text-neon-mint" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-display text-lg text-gray-200">Create New File</h3>
|
||||
{folderPath && (
|
||||
<p className="font-console text-xs text-shell-500 truncate">
|
||||
in {folderPath.split('/').pop()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* File name input */}
|
||||
<div>
|
||||
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
|
||||
File Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FileText size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="example.md"
|
||||
className={`w-full bg-shell-800 border rounded-lg pl-10 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:ring-1 ${
|
||||
error
|
||||
? 'border-crab-500 focus:border-crab-500 focus:ring-crab-500/20'
|
||||
: 'border-shell-700 focus:border-neon-mint focus:ring-neon-mint/20'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-crab-400">
|
||||
<AlertCircle size={12} />
|
||||
<span className="font-console text-xs">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick extension buttons */}
|
||||
<div>
|
||||
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
|
||||
Quick Extensions
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{['.md', '.txt', '.json', '.html'].map((ext) => (
|
||||
<button
|
||||
key={ext}
|
||||
onClick={() => addExtension(ext)}
|
||||
className="px-3 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-xs font-console text-gray-400 transition-colors border border-shell-700"
|
||||
>
|
||||
{ext}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional content */}
|
||||
<div>
|
||||
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
|
||||
Initial Content (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter file content..."
|
||||
className="w-full h-32 bg-shell-800 border border-shell-700 rounded-lg p-3 text-sm font-mono text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-neon-mint focus:ring-1 focus:ring-neon-mint/20"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-neon-mint hover:bg-neon-mint/90 rounded-lg text-sm font-console text-shell-950 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create File
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewFileDialog
|
||||
@@ -0,0 +1,8 @@
|
||||
export { FileTree } from './FileTree'
|
||||
export { FileEditor } from './FileEditor'
|
||||
export { MobileBottomToolbar } from './MobileBottomToolbar'
|
||||
export { MobileFileDrawer } from './MobileFileDrawer'
|
||||
export { MobilePathSheet } from './MobilePathSheet'
|
||||
export { ConfirmationDialog } from './ConfirmationDialog'
|
||||
export { FileContextMenu, FileIcon, TrashIcon, CopyIcon, EditIcon, NewFileIcon } from './FileContextMenu'
|
||||
export { NewFileDialog } from './NewFileDialog'
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useIsMobile(breakpoint = 640) {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
|
||||
setIsMobile(mql.matches)
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mql.addEventListener('change', handler)
|
||||
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}, [breakpoint])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
type GatewayFrame,
|
||||
type RequestFrame,
|
||||
type ResponseFrame,
|
||||
type EventFrame,
|
||||
type HelloOk,
|
||||
type ChatEvent,
|
||||
type AgentEvent,
|
||||
type SessionInfo,
|
||||
type SessionsListParams,
|
||||
createConnectParams,
|
||||
} from './protocol'
|
||||
|
||||
interface ChallengePayload {
|
||||
nonce: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
type EventCallback = (event: EventFrame) => void
|
||||
|
||||
export class ClawdbotClient {
|
||||
private ws: WebSocket | null = null
|
||||
private requestId = 0
|
||||
private pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
||||
>()
|
||||
private eventListeners: EventCallback[] = []
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _connected = false
|
||||
private _connecting = false
|
||||
|
||||
constructor(
|
||||
private url: string = 'ws://127.0.0.1:18789',
|
||||
private token?: string
|
||||
) {}
|
||||
|
||||
get connected() {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
async connect(): Promise<HelloOk> {
|
||||
if (this._connecting || this._connected) {
|
||||
return { type: 'hello-ok', protocol: 3 } as HelloOk
|
||||
}
|
||||
this._connecting = true
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this._connecting = false
|
||||
this.ws?.close()
|
||||
reject(new Error('Connection timeout - is clawdbot gateway running?'))
|
||||
}, 10000)
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (e) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`Failed to create WebSocket: ${e}`))
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.once('open', () => {
|
||||
// WebSocket connected, waiting for challenge
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const raw = data.toString()
|
||||
const msg = JSON.parse(raw)
|
||||
|
||||
// Handle challenge-response auth
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
this.handleChallenge(msg.payload as ChallengePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.handleMessage(msg, resolve, reject, timeout)
|
||||
} catch (e) {
|
||||
console.error('[clawdbot] Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
this._connecting = false
|
||||
reject(err)
|
||||
})
|
||||
|
||||
this.ws.on('close', (code, _reason) => {
|
||||
clearTimeout(timeout)
|
||||
const wasConnected = this._connected
|
||||
this._connected = false
|
||||
this._connecting = false
|
||||
// Only reconnect if we were previously connected and it wasn't a clean close
|
||||
if (wasConnected && code !== 1000) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleChallenge(_challenge: ChallengePayload) {
|
||||
if (!this.token || this.ws?.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = createConnectParams(this.token)
|
||||
const response: RequestFrame = {
|
||||
type: 'req',
|
||||
id: `connect-${Date.now()}`,
|
||||
method: 'connect',
|
||||
params,
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
msg: GatewayFrame | HelloOk,
|
||||
connectResolve?: (v: HelloOk) => void,
|
||||
_connectReject?: (e: Error) => void,
|
||||
connectTimeout?: ReturnType<typeof setTimeout>
|
||||
) {
|
||||
if ('type' in msg) {
|
||||
switch (msg.type) {
|
||||
case 'hello-ok':
|
||||
if (connectTimeout) clearTimeout(connectTimeout)
|
||||
this._connected = true
|
||||
connectResolve?.(msg)
|
||||
break
|
||||
|
||||
case 'res':
|
||||
// Check if this is the hello-ok response to our connect request
|
||||
if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') {
|
||||
if (connectTimeout) clearTimeout(connectTimeout)
|
||||
this._connected = true
|
||||
this._connecting = false
|
||||
connectResolve?.(msg.payload as HelloOk)
|
||||
} else {
|
||||
this.handleResponse(msg)
|
||||
}
|
||||
break
|
||||
|
||||
case 'event':
|
||||
this.handleEvent(msg)
|
||||
break
|
||||
|
||||
case 'req':
|
||||
// Server shouldn't send requests to us
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(res: ResponseFrame) {
|
||||
const pending = this.pendingRequests.get(res.id)
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(res.id)
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload)
|
||||
} else {
|
||||
pending.reject(new Error(res.error?.message || 'Request failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: EventFrame) {
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
console.error('Event listener error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect().catch(console.error)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async request<T>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
const id = `req-${++this.requestId}`
|
||||
const req: RequestFrame = { type: 'req', id, method, params }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
this.ws!.send(JSON.stringify(req))
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error('Request timeout'))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
onEvent(callback: EventCallback): () => void {
|
||||
this.eventListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.eventListeners.indexOf(callback)
|
||||
if (idx >= 0) this.eventListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(params?: SessionsListParams): Promise<SessionInfo[]> {
|
||||
const result = await this.request<{ sessions: SessionInfo[] }>(
|
||||
'sessions.list',
|
||||
params
|
||||
)
|
||||
return result.sessions ?? []
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this._connected = false
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for server use
|
||||
let clientInstance: ClawdbotClient | null = null
|
||||
|
||||
export function getClawdbotClient(): ClawdbotClient {
|
||||
if (!clientInstance) {
|
||||
const token = process.env.CLAWDBOT_API_TOKEN
|
||||
clientInstance = new ClawdbotClient('ws://127.0.0.1:18789', token)
|
||||
}
|
||||
return clientInstance
|
||||
}
|
||||
|
||||
// Parsed event helpers
|
||||
export function isChatEvent(
|
||||
event: EventFrame
|
||||
): event is EventFrame & { payload: ChatEvent } {
|
||||
return event.event === 'chat' && event.payload != null
|
||||
}
|
||||
|
||||
export function isAgentEvent(
|
||||
event: EventFrame
|
||||
): event is EventFrame & { payload: AgentEvent } {
|
||||
return event.event === 'agent' && event.payload != null
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
|
||||
import type { MonitorSession, MonitorAction } from './protocol'
|
||||
|
||||
// Track runId → sessionKey mapping (learned from chat events)
|
||||
const runSessionMap = new Map<string, string>()
|
||||
|
||||
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) {
|
||||
const existing = sessionsCollection.state.get(session.key)
|
||||
if (existing) {
|
||||
sessionsCollection.update(session.key, (draft) => {
|
||||
Object.assign(draft, session)
|
||||
})
|
||||
} else {
|
||||
sessionsCollection.insert(session)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Append content and update sessionKey if we learned it
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
if (action.content) {
|
||||
draft.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 session = sessionsCollection.state.get(key)
|
||||
if (session) {
|
||||
sessionsCollection.update(key, (draft) => {
|
||||
draft.status = status
|
||||
draft.lastActivityAt = Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
sessionsCollection.delete(session.key)
|
||||
}
|
||||
for (const action of actionsCollection.state.values()) {
|
||||
actionsCollection.delete(action.id)
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
// Clawdbot Gateway Protocol v3 types
|
||||
|
||||
// Frame types
|
||||
export interface RequestFrame {
|
||||
type: 'req'
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export interface ResponseFrame {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: boolean
|
||||
payload?: unknown
|
||||
error?: { code: string; message: string }
|
||||
}
|
||||
|
||||
export interface EventFrame {
|
||||
type: 'event'
|
||||
event: string
|
||||
payload?: unknown
|
||||
seq?: number
|
||||
stateVersion?: { presence: number; health: number }
|
||||
}
|
||||
|
||||
export type GatewayFrame = RequestFrame | ResponseFrame | EventFrame
|
||||
|
||||
// Connection
|
||||
export interface ClientInfo {
|
||||
id: string
|
||||
displayName: string
|
||||
version: string
|
||||
platform: string
|
||||
mode: 'ui' | 'cli' | 'bot'
|
||||
}
|
||||
|
||||
export interface ConnectParams {
|
||||
minProtocol: 3
|
||||
maxProtocol: 3
|
||||
client: ClientInfo
|
||||
auth?: { token?: string }
|
||||
}
|
||||
|
||||
export interface HelloOk {
|
||||
type: 'hello-ok'
|
||||
protocol: number
|
||||
snapshot: {
|
||||
presence: PresenceEntry[]
|
||||
health: unknown
|
||||
stateVersion: { presence: number; health: number }
|
||||
}
|
||||
features: { methods: string[]; events: string[] }
|
||||
}
|
||||
|
||||
export interface PresenceEntry {
|
||||
key: string
|
||||
client: ClientInfo
|
||||
connectedAt: number
|
||||
}
|
||||
|
||||
// Chat events
|
||||
export interface ChatEvent {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
state: 'delta' | 'final' | 'aborted' | 'error'
|
||||
message?: unknown
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
}
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
// Agent events
|
||||
export interface AgentEvent {
|
||||
runId: string
|
||||
seq: number
|
||||
stream: string
|
||||
ts: number
|
||||
data: Record<string, unknown>
|
||||
sessionKey?: string
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export interface SessionsListParams {
|
||||
limit?: number
|
||||
activeMinutes?: number
|
||||
includeLastMessage?: boolean
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
key: string
|
||||
agentId: string
|
||||
createdAt: number
|
||||
lastActivityAt: number
|
||||
messageCount: number
|
||||
lastMessage?: unknown
|
||||
}
|
||||
|
||||
// App-level types
|
||||
export interface MonitorSession {
|
||||
key: string
|
||||
agentId: string
|
||||
platform: string
|
||||
recipient: string
|
||||
isGroup: boolean
|
||||
lastActivityAt: number
|
||||
status: 'idle' | 'active' | 'thinking'
|
||||
}
|
||||
|
||||
export interface MonitorAction {
|
||||
id: string
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
|
||||
eventType: 'chat' | 'agent' | 'system'
|
||||
timestamp: number
|
||||
content?: string
|
||||
toolName?: string
|
||||
toolArgs?: unknown
|
||||
// Metadata from lifecycle/chat events
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
duration?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export function parseSessionKey(key: string): {
|
||||
agentId: string
|
||||
platform: string
|
||||
recipient: string
|
||||
isGroup: boolean
|
||||
} {
|
||||
// Format: "agent:main:discord:channel:1234567890"
|
||||
// Or: "agent:main:telegram:group:12345"
|
||||
// Or: "agent:main:whatsapp:+1234567890"
|
||||
const parts = key.split(':')
|
||||
const agentId = parts[1] || 'unknown'
|
||||
const platform = parts[2] || 'unknown'
|
||||
// Check if 4th part indicates a type (channel, group, dm, etc)
|
||||
const hasType = ['channel', 'group', 'dm', 'thread'].includes(parts[3] || '')
|
||||
const isGroup = parts[3] === 'group' || parts[3] === 'channel'
|
||||
const recipient = hasType ? parts.slice(3).join(':') : parts.slice(3).join(':')
|
||||
|
||||
return { agentId, platform, recipient, isGroup }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function createConnectParams(token?: string): any {
|
||||
return {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'cli',
|
||||
version: '0.1.0',
|
||||
platform: 'linux',
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
locale: 'en-US',
|
||||
userAgent: 'crabwalk-monitor/0.1.0',
|
||||
auth: token ? { token } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
type GatewayFrame,
|
||||
type RequestFrame,
|
||||
type ResponseFrame,
|
||||
type EventFrame,
|
||||
type HelloOk,
|
||||
type ChatEvent,
|
||||
type AgentEvent,
|
||||
type SessionInfo,
|
||||
type SessionsListParams,
|
||||
type ConnectChallengePayload,
|
||||
createConnectParams,
|
||||
} from './protocol'
|
||||
import {
|
||||
buildSignedDevice,
|
||||
clearStoredDeviceToken,
|
||||
getOrCreateIdentity,
|
||||
loadStoredDeviceToken,
|
||||
saveStoredDeviceToken,
|
||||
} from './device'
|
||||
|
||||
const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
const DEFAULT_SCOPES = ['operator.read'] as const
|
||||
|
||||
type EventCallback = (event: EventFrame) => void
|
||||
export type GatewayAuthState =
|
||||
| 'unknown'
|
||||
| 'authorized'
|
||||
| 'unpaired'
|
||||
| 'unauthorized'
|
||||
| 'degraded'
|
||||
|
||||
interface PairingInfo {
|
||||
requestId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
function normalized(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value.trim() || undefined : undefined
|
||||
}
|
||||
|
||||
function isTrustedLoopback(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline of openclaw selectGatewayConnectAuth / buildGatewayConnectAuth (operator subset). */
|
||||
function selectConnectAuth(params: {
|
||||
envToken?: string
|
||||
storedToken?: string
|
||||
storedScopes?: string[]
|
||||
pendingDeviceTokenRetry?: boolean
|
||||
trustedDeviceTokenRetry?: boolean
|
||||
}) {
|
||||
const authToken = normalized(params.envToken)
|
||||
const storedToken = normalized(params.storedToken)
|
||||
const useRetryToken =
|
||||
params.pendingDeviceTokenRetry === true &&
|
||||
Boolean(authToken && storedToken && params.trustedDeviceTokenRetry)
|
||||
// Reference: resolved when retry OR (!(authToken) && stored)
|
||||
const resolvedDeviceToken =
|
||||
useRetryToken || (!authToken && storedToken) ? storedToken : undefined
|
||||
const usingStoredDeviceToken =
|
||||
Boolean(resolvedDeviceToken && storedToken) && resolvedDeviceToken === storedToken
|
||||
const selectedToken = authToken ?? resolvedDeviceToken
|
||||
return {
|
||||
authToken: selectedToken,
|
||||
// buildGatewayConnectAuth: deviceToken = authDeviceToken ?? resolvedDeviceToken
|
||||
// select sets authDeviceToken only on retry; resolved covers stored-as-primary
|
||||
authDeviceToken: (useRetryToken ? storedToken : undefined) ?? resolvedDeviceToken,
|
||||
signatureToken: selectedToken ?? null,
|
||||
usingStoredDeviceToken,
|
||||
/** Stored token is auth.token (no env) — for clear-and-retry path. */
|
||||
usingStoredAsPrimary: Boolean(!authToken && selectedToken && selectedToken === storedToken),
|
||||
storedScopes: params.storedScopes,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryWithDeviceToken(params: {
|
||||
retryBudgetUsed: boolean
|
||||
currentDeviceToken?: string
|
||||
explicitToken?: string
|
||||
storedToken?: string
|
||||
trustedEndpoint: boolean
|
||||
error?: { code?: string; message?: string; details?: unknown }
|
||||
}): boolean {
|
||||
if (
|
||||
params.retryBudgetUsed ||
|
||||
params.currentDeviceToken ||
|
||||
!params.explicitToken ||
|
||||
!params.storedToken ||
|
||||
!params.trustedEndpoint
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const code = params.error?.code ?? ''
|
||||
const message = (params.error?.message ?? '').toLowerCase()
|
||||
const details = JSON.stringify(params.error?.details ?? '')
|
||||
return (
|
||||
code === 'AUTH_TOKEN_MISMATCH' ||
|
||||
message.includes('auth_token_mismatch') ||
|
||||
message.includes('retry_with_device_token') ||
|
||||
details.includes('retry_with_device_token') ||
|
||||
details.includes('AUTH_TOKEN_MISMATCH')
|
||||
)
|
||||
}
|
||||
|
||||
function shouldRetryClearStoredToken(params: {
|
||||
retryBudgetUsed: boolean
|
||||
usingStoredAsPrimary: boolean
|
||||
envToken?: string
|
||||
error?: { code?: string; message?: string; details?: unknown }
|
||||
}): boolean {
|
||||
if (params.retryBudgetUsed || !params.usingStoredAsPrimary || !params.envToken) return false
|
||||
const code = params.error?.code ?? ''
|
||||
const message = (params.error?.message ?? '').toLowerCase()
|
||||
const details = JSON.stringify(params.error?.details ?? '')
|
||||
return (
|
||||
code === 'AUTH_TOKEN_MISMATCH' ||
|
||||
message.includes('auth_token_mismatch') ||
|
||||
message.includes('retry_with_device_token') ||
|
||||
details.includes('AUTH_TOKEN_MISMATCH')
|
||||
)
|
||||
}
|
||||
|
||||
export class ClawdbotClient {
|
||||
private ws: WebSocket | null = null
|
||||
private requestId = 0
|
||||
private pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
||||
>()
|
||||
private eventListeners: EventCallback[] = []
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _connected = false
|
||||
private _connecting = false
|
||||
private _authState: GatewayAuthState = 'unknown'
|
||||
private _scopes: string[] = []
|
||||
private _pairingInfo: PairingInfo | null = null
|
||||
private _connectPromiseSettled = false
|
||||
private readonly debugEnabled = process.env.CRABWALK_DEBUG_OPENCLAW === '1'
|
||||
/** One-shot AUTH_TOKEN_MISMATCH retry within a connect attempt. */
|
||||
private _authRetryUsed = false
|
||||
private _pendingDeviceTokenRetry = false
|
||||
private _lastConnectAuth: {
|
||||
authDeviceToken?: string
|
||||
usingStoredAsPrimary: boolean
|
||||
} | null = null
|
||||
private _connectResolve?: (v: HelloOk) => void
|
||||
private _connectReject?: (e: Error) => void
|
||||
private _connectTimeout?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(
|
||||
private url: string = DEFAULT_GATEWAY_URL,
|
||||
private token?: string
|
||||
) {}
|
||||
|
||||
get connected() {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get authState() {
|
||||
return this._authState
|
||||
}
|
||||
|
||||
get scopes() {
|
||||
return [...this._scopes]
|
||||
}
|
||||
|
||||
get pairingInfo() {
|
||||
return this._pairingInfo
|
||||
}
|
||||
|
||||
async connect(): Promise<HelloOk> {
|
||||
if (this._connecting || this._connected) {
|
||||
return { type: 'hello-ok', protocol: 4 } as HelloOk
|
||||
}
|
||||
this._connecting = true
|
||||
this._connectPromiseSettled = false
|
||||
this._authRetryUsed = false
|
||||
this._pendingDeviceTokenRetry = false
|
||||
this._lastConnectAuth = null
|
||||
return new Promise((resolve, reject) => {
|
||||
this._connectResolve = resolve
|
||||
this._connectReject = reject
|
||||
const timeout = setTimeout(() => {
|
||||
this._connecting = false
|
||||
this.ws?.close()
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
reject(new Error('Connection timeout - is openclaw gateway running?'))
|
||||
}
|
||||
}, 10000)
|
||||
this._connectTimeout = timeout
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (e) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`Failed to create WebSocket: ${e}`))
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.once('open', () => {
|
||||
this.debugLog('socket open, waiting for connect.challenge')
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const raw = data.toString()
|
||||
const msg = JSON.parse(raw)
|
||||
|
||||
// Handle challenge-response auth
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
this.handleChallenge(msg.payload as ConnectChallengePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
this._connecting = false
|
||||
this.debugLog('socket error before connect', err)
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
clearTimeout(timeout)
|
||||
const wasConnected = this._connected
|
||||
const wasConnecting = this._connecting
|
||||
this.debugLog('socket close', {
|
||||
code,
|
||||
reason: reason?.toString?.() ?? '',
|
||||
wasConnected,
|
||||
wasConnecting,
|
||||
})
|
||||
this._connected = false
|
||||
this._connecting = false
|
||||
if (!wasConnected && wasConnecting && !this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
reject(
|
||||
new Error(
|
||||
`Gateway closed before connect (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})`
|
||||
)
|
||||
)
|
||||
}
|
||||
// Only reconnect if we were previously connected and it wasn't a clean close
|
||||
if (wasConnected && code !== 1000) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleChallenge(challenge: ConnectChallengePayload) {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
const stored = loadStoredDeviceToken()
|
||||
const selected = selectConnectAuth({
|
||||
envToken: this.token,
|
||||
storedToken: stored?.token,
|
||||
storedScopes: stored?.scopes,
|
||||
pendingDeviceTokenRetry: this._pendingDeviceTokenRetry,
|
||||
trustedDeviceTokenRetry: isTrustedLoopback(this.url),
|
||||
})
|
||||
|
||||
const scopes =
|
||||
selected.usingStoredDeviceToken && stored?.scopes?.length
|
||||
? stored.scopes
|
||||
: [...DEFAULT_SCOPES]
|
||||
|
||||
let params = createConnectParams({
|
||||
token: selected.authToken,
|
||||
deviceToken: selected.authDeviceToken,
|
||||
scopes,
|
||||
})
|
||||
|
||||
// Payload platform must match client.platform after normalize (createConnectParams sets raw platform)
|
||||
try {
|
||||
const device = buildSignedDevice({
|
||||
challenge,
|
||||
token: selected.signatureToken,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
clientId: params.client.id,
|
||||
clientMode: params.client.mode,
|
||||
platform: params.client.platform,
|
||||
})
|
||||
params = createConnectParams({
|
||||
token: selected.authToken,
|
||||
deviceToken: selected.authDeviceToken,
|
||||
scopes,
|
||||
device,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[openclaw] Failed to create signed device identity:', error)
|
||||
}
|
||||
|
||||
this._lastConnectAuth = {
|
||||
authDeviceToken: selected.authDeviceToken,
|
||||
usingStoredAsPrimary: selected.usingStoredAsPrimary,
|
||||
}
|
||||
|
||||
this.debugLog('sending connect', {
|
||||
hasToken: Boolean(params.auth?.token),
|
||||
hasDeviceToken: Boolean(params.auth?.deviceToken),
|
||||
hasDevice: Boolean(params.device),
|
||||
deviceId: params.device?.id,
|
||||
usingStored: selected.usingStoredDeviceToken,
|
||||
pendingRetry: this._pendingDeviceTokenRetry,
|
||||
clientMode: params.client.mode,
|
||||
clientPlatform: params.client.platform,
|
||||
scopes: params.scopes,
|
||||
})
|
||||
|
||||
const response: RequestFrame = {
|
||||
type: 'req',
|
||||
id: `connect-${Date.now()}`,
|
||||
method: 'connect',
|
||||
params,
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
private handleConnectFailure(error?: { code?: string; message?: string; details?: unknown }) {
|
||||
const stored = loadStoredDeviceToken()
|
||||
const trusted = isTrustedLoopback(this.url)
|
||||
const last = this._lastConnectAuth
|
||||
|
||||
// Env primary failed → retry once with cached device token
|
||||
if (
|
||||
shouldRetryWithDeviceToken({
|
||||
retryBudgetUsed: this._authRetryUsed,
|
||||
currentDeviceToken: last?.authDeviceToken,
|
||||
explicitToken: this.token,
|
||||
storedToken: stored?.token,
|
||||
trustedEndpoint: trusted,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
this._authRetryUsed = true
|
||||
this._pendingDeviceTokenRetry = true
|
||||
this.debugLog('AUTH_TOKEN_MISMATCH — retrying with device token')
|
||||
this.reopenForAuthRetry()
|
||||
return
|
||||
}
|
||||
|
||||
// Stored primary failed → clear token file, retry once with env token only
|
||||
if (
|
||||
shouldRetryClearStoredToken({
|
||||
retryBudgetUsed: this._authRetryUsed,
|
||||
usingStoredAsPrimary: Boolean(last?.usingStoredAsPrimary),
|
||||
envToken: this.token,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
this._authRetryUsed = true
|
||||
this._pendingDeviceTokenRetry = false
|
||||
clearStoredDeviceToken()
|
||||
this.debugLog('AUTH_TOKEN_MISMATCH — cleared stored device token, retrying with env')
|
||||
this.reopenForAuthRetry()
|
||||
return
|
||||
}
|
||||
|
||||
const message = error?.message || 'Connect failed'
|
||||
this.updateAuthStateFromError(message)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this._connecting = false
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connectReject?.(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-open socket for one-shot auth retry without settling the outer connect promise. */
|
||||
private reopenForAuthRetry() {
|
||||
// Detach old socket so its close handler cannot reject the connect promise.
|
||||
const old = this.ws
|
||||
this.ws = null
|
||||
if (old) {
|
||||
old.removeAllListeners()
|
||||
old.on('error', () => {})
|
||||
try {
|
||||
old.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this._connected = false
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (e) {
|
||||
this._connecting = false
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connectReject?.(new Error(`Failed to create WebSocket for auth retry: ${e}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.once('open', () => {
|
||||
this.debugLog('auth-retry socket open, waiting for connect.challenge')
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
this.handleChallenge(msg.payload as ConnectChallengePayload)
|
||||
return
|
||||
}
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
this.debugLog('auth-retry socket error', err)
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connecting = false
|
||||
this._connectReject?.(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
this.debugLog('auth-retry socket close', {
|
||||
code,
|
||||
reason: reason?.toString?.() ?? '',
|
||||
})
|
||||
if (this._connecting && !this._connectPromiseSettled && !this._connected) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connecting = false
|
||||
this._connectReject?.(
|
||||
new Error(
|
||||
`Gateway closed during auth retry (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: GatewayFrame | HelloOk) {
|
||||
if ('type' in msg) {
|
||||
switch (msg.type) {
|
||||
case 'hello-ok':
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this.updateAuthStateFromHello(msg)
|
||||
this._connected = true
|
||||
this._connecting = false
|
||||
this._connectPromiseSettled = true
|
||||
this._connectResolve?.(msg)
|
||||
break
|
||||
|
||||
case 'res':
|
||||
// Check if this is the hello-ok response to our connect request
|
||||
if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') {
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this.updateAuthStateFromHello(msg.payload as HelloOk)
|
||||
this._connected = true
|
||||
this._connecting = false
|
||||
this._connectPromiseSettled = true
|
||||
this._connectResolve?.(msg.payload as HelloOk)
|
||||
} else if (!msg.ok && String(msg.id).startsWith('connect-')) {
|
||||
this.handleConnectFailure(msg.error)
|
||||
} else {
|
||||
this.handleResponse(msg)
|
||||
}
|
||||
break
|
||||
|
||||
case 'event':
|
||||
this.handleEvent(msg)
|
||||
break
|
||||
|
||||
case 'req':
|
||||
// Server shouldn't send requests to us
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(res: ResponseFrame) {
|
||||
const pending = this.pendingRequests.get(res.id)
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(res.id)
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload)
|
||||
} else {
|
||||
const message = res.error?.message || 'Request failed'
|
||||
this.updateAuthStateFromError(message)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: EventFrame) {
|
||||
if (event.event.includes('pair') || event.event.includes('device')) {
|
||||
const payload = event.payload as { requestId?: string; message?: string } | undefined
|
||||
if (payload?.requestId || payload?.message) {
|
||||
this._authState = 'unpaired'
|
||||
this._pairingInfo = {
|
||||
requestId: payload.requestId,
|
||||
message: payload.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
console.error('Event listener error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect().catch(console.error)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async request<T>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
const id = `req-${++this.requestId}`
|
||||
const req: RequestFrame = { type: 'req', id, method, params }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
this.ws!.send(JSON.stringify(req))
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error('Request timeout'))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
onEvent(callback: EventCallback): () => void {
|
||||
this.eventListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.eventListeners.indexOf(callback)
|
||||
if (idx >= 0) this.eventListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(params?: SessionsListParams): Promise<SessionInfo[]> {
|
||||
const result = await this.request<{ sessions: SessionInfo[] }>(
|
||||
'sessions.list',
|
||||
params
|
||||
)
|
||||
return result.sessions ?? []
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this._connected = false
|
||||
this._authState = 'unknown'
|
||||
this._scopes = []
|
||||
this._pairingInfo = null
|
||||
}
|
||||
|
||||
private updateAuthStateFromHello(hello: HelloOk) {
|
||||
const scopes = hello.auth?.scopes
|
||||
this._scopes = scopes ? [...scopes] : []
|
||||
|
||||
if (hello.auth?.deviceToken) {
|
||||
const identity = getOrCreateIdentity()
|
||||
saveStoredDeviceToken({
|
||||
deviceId: identity.id,
|
||||
token: hello.auth.deviceToken,
|
||||
role: hello.auth.role,
|
||||
scopes: hello.auth.scopes,
|
||||
updatedAtMs: Date.now(),
|
||||
})
|
||||
this.debugLog('persisted device token')
|
||||
}
|
||||
|
||||
if (!scopes) {
|
||||
this._authState = 'authorized'
|
||||
return
|
||||
}
|
||||
|
||||
if (scopes.includes('operator.read')) {
|
||||
this._authState = 'authorized'
|
||||
this._pairingInfo = null
|
||||
this.debugLog('authorized scopes', scopes)
|
||||
return
|
||||
}
|
||||
|
||||
this._authState = scopes.length === 0 ? 'unpaired' : 'degraded'
|
||||
this.debugLog('non-authorized scopes', scopes)
|
||||
}
|
||||
|
||||
private updateAuthStateFromError(message: string) {
|
||||
const lowered = message.toLowerCase()
|
||||
if (lowered.includes('missing scope') || lowered.includes('operator.read')) {
|
||||
this._authState = 'unpaired'
|
||||
const requestId = this.extractRequestId(message)
|
||||
this._pairingInfo = {
|
||||
requestId: requestId ?? this._pairingInfo?.requestId,
|
||||
message,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (lowered.includes('unauthorized') || lowered.includes('forbidden')) {
|
||||
this._authState = 'unauthorized'
|
||||
this._pairingInfo = { message }
|
||||
}
|
||||
}
|
||||
|
||||
private debugLog(message: string, payload?: unknown) {
|
||||
if (!this.debugEnabled) return
|
||||
if (payload !== undefined) {
|
||||
console.log(`[openclaw][debug] ${message}`, payload)
|
||||
return
|
||||
}
|
||||
console.log(`[openclaw][debug] ${message}`)
|
||||
}
|
||||
|
||||
private extractRequestId(message: string): string | undefined {
|
||||
const explicitMatch = message.match(/request(?:\s+id)?[:=\s]+([a-zA-Z0-9_-]+)/i)
|
||||
if (explicitMatch?.[1]) {
|
||||
return explicitMatch[1]
|
||||
}
|
||||
|
||||
const uuidMatch = message.match(
|
||||
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i
|
||||
)
|
||||
return uuidMatch?.[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for server use
|
||||
let clientInstance: ClawdbotClient | null = null
|
||||
|
||||
export function getClawdbotEndpoint(): string {
|
||||
return DEFAULT_GATEWAY_URL
|
||||
}
|
||||
|
||||
export function getClawdbotClient(): ClawdbotClient {
|
||||
if (!clientInstance) {
|
||||
const token = process.env.CLAWDBOT_API_TOKEN
|
||||
clientInstance = new ClawdbotClient(DEFAULT_GATEWAY_URL, token)
|
||||
}
|
||||
return clientInstance
|
||||
}
|
||||
|
||||
// Parsed event helpers
|
||||
export function isChatEvent(
|
||||
event: EventFrame
|
||||
): event is EventFrame & { payload: ChatEvent } {
|
||||
return event.event === 'chat' && event.payload != null
|
||||
}
|
||||
|
||||
export function isAgentEvent(
|
||||
event: EventFrame
|
||||
): event is EventFrame & { payload: AgentEvent } {
|
||||
return event.event === 'agent' && event.payload != null
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Self-check for v3 device-auth payload (no test framework).
|
||||
* Run: node --experimental-strip-types src/integrations/openclaw/device-auth.selfcheck.ts
|
||||
*/
|
||||
import {
|
||||
buildDeviceAuthPayloadV3,
|
||||
normalizeDeviceMetadataForAuth,
|
||||
} from './device.ts'
|
||||
|
||||
function assert(cond: unknown, msg: string): asserts cond {
|
||||
if (!cond) throw new Error(msg)
|
||||
}
|
||||
|
||||
const platform = normalizeDeviceMetadataForAuth('Linux')
|
||||
assert(platform === 'linux', `normalize DeviceMetadata: expected 'linux', got '${platform}'`)
|
||||
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: 'abc123',
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
signedAtMs: 1700000000000,
|
||||
token: 'tok',
|
||||
nonce: 'nonce-1',
|
||||
platform: 'Linux',
|
||||
deviceFamily: '',
|
||||
})
|
||||
|
||||
const expected =
|
||||
'v3|abc123|cli|cli|operator|operator.read|1700000000000|tok|nonce-1|linux|'
|
||||
|
||||
assert(payload === expected, `payload mismatch:\n got: ${payload}\n exp: ${expected}`)
|
||||
|
||||
console.log('device-auth.selfcheck: ok')
|
||||
@@ -0,0 +1,242 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
} from 'crypto'
|
||||
import type { ConnectChallengePayload, ConnectDevice } from './protocol'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const DEVICE_IDENTITY_FILE = path.join(DATA_DIR, 'device-identity.json')
|
||||
const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.json')
|
||||
|
||||
interface StoredDeviceIdentity {
|
||||
id: string
|
||||
publicKey: string
|
||||
privateKeyPem: string
|
||||
createdAt: number
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface StoredDeviceToken {
|
||||
deviceId: string
|
||||
token: string
|
||||
role?: string
|
||||
scopes?: string[]
|
||||
updatedAtMs: number
|
||||
}
|
||||
|
||||
function base64UrlToBuffer(value: string): Buffer {
|
||||
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=')
|
||||
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(base64, 'base64')
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: Buffer): string {
|
||||
return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function decodePublicKey(value: string): Buffer {
|
||||
try {
|
||||
const raw = base64UrlToBuffer(value)
|
||||
if (raw.length > 0) return raw
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
return Buffer.from(value, 'base64')
|
||||
}
|
||||
|
||||
function fingerprintFromPublicKey(publicKey: string): string {
|
||||
const rawPublicKey = decodePublicKey(publicKey)
|
||||
return createHash('sha256').update(rawPublicKey).digest('hex')
|
||||
}
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredIdentity(): StoredDeviceIdentity | null {
|
||||
try {
|
||||
if (!fs.existsSync(DEVICE_IDENTITY_FILE)) {
|
||||
return null
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(DEVICE_IDENTITY_FILE, 'utf-8')) as StoredDeviceIdentity
|
||||
if (!data.publicKey || !data.privateKeyPem) {
|
||||
return null
|
||||
}
|
||||
const canonicalId = fingerprintFromPublicKey(data.publicKey)
|
||||
const normalized: StoredDeviceIdentity = {
|
||||
...data,
|
||||
id: canonicalId,
|
||||
}
|
||||
// Auto-migrate legacy id formats to canonical fingerprint.
|
||||
if (data.id !== canonicalId) {
|
||||
saveStoredIdentity(normalized)
|
||||
}
|
||||
return normalized
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredIdentity(identity: StoredDeviceIdentity) {
|
||||
ensureDataDir()
|
||||
fs.writeFileSync(DEVICE_IDENTITY_FILE, JSON.stringify(identity, null, 2), { mode: 0o600 })
|
||||
try {
|
||||
fs.chmodSync(DEVICE_IDENTITY_FILE, 0o600)
|
||||
} catch {
|
||||
// ponytail: chmod best-effort on platforms that ignore mode
|
||||
}
|
||||
}
|
||||
|
||||
function generateStoredIdentity(): StoredDeviceIdentity {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||
const publicJwk = publicKey.export({ format: 'jwk' })
|
||||
if (!publicJwk.x) {
|
||||
throw new Error('Failed to export Ed25519 public key')
|
||||
}
|
||||
|
||||
const rawPublicKey = base64UrlToBuffer(publicJwk.x)
|
||||
const fingerprint = createHash('sha256').update(rawPublicKey).digest('hex')
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
id: fingerprint,
|
||||
publicKey: base64UrlEncode(rawPublicKey),
|
||||
privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
|
||||
createdAt: now,
|
||||
lastUsedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
/** Verbatim from openclaw gateway-client device-auth.ts */
|
||||
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32))
|
||||
}
|
||||
|
||||
export function buildDeviceAuthPayloadV3(params: {
|
||||
deviceId: string
|
||||
clientId: string
|
||||
clientMode: string
|
||||
role: string
|
||||
scopes: string[]
|
||||
signedAtMs: number
|
||||
token?: string | null
|
||||
nonce: string
|
||||
platform?: string | null
|
||||
deviceFamily?: string | null
|
||||
}): string {
|
||||
const scopes = params.scopes.join(',')
|
||||
const token = params.token ?? ''
|
||||
const platform = normalizeDeviceMetadataForAuth(params.platform)
|
||||
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily)
|
||||
return [
|
||||
'v3',
|
||||
params.deviceId,
|
||||
params.clientId,
|
||||
params.clientMode,
|
||||
params.role,
|
||||
scopes,
|
||||
String(params.signedAtMs),
|
||||
token,
|
||||
params.nonce,
|
||||
platform,
|
||||
deviceFamily,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
interface BuildSignedDeviceParams {
|
||||
challenge: ConnectChallengePayload
|
||||
token: string | null
|
||||
role: string
|
||||
scopes: string[]
|
||||
clientId: string
|
||||
clientMode: string
|
||||
platform?: string | null
|
||||
deviceFamily?: string | null
|
||||
}
|
||||
|
||||
export function getOrCreateIdentity(): StoredDeviceIdentity {
|
||||
const existing = loadStoredIdentity()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const generated = generateStoredIdentity()
|
||||
saveStoredIdentity(generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevice {
|
||||
const nonce = params.challenge.nonce?.trim()
|
||||
if (!nonce) {
|
||||
throw new Error('connect.challenge nonce is empty — refusing to sign (would fall back to v1)')
|
||||
}
|
||||
|
||||
const identity = getOrCreateIdentity()
|
||||
const privateKey = createPrivateKey(identity.privateKeyPem)
|
||||
const signedAt = Date.now()
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: identity.id,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
signedAtMs: signedAt,
|
||||
token: params.token,
|
||||
nonce,
|
||||
platform: params.platform,
|
||||
deviceFamily: params.deviceFamily ?? '',
|
||||
})
|
||||
const signature = base64UrlEncode(sign(null, Buffer.from(payload, 'utf8'), privateKey))
|
||||
|
||||
identity.lastUsedAt = signedAt
|
||||
saveStoredIdentity(identity)
|
||||
|
||||
return {
|
||||
id: identity.id,
|
||||
publicKey: identity.publicKey,
|
||||
signature,
|
||||
signedAt,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredDeviceToken(): StoredDeviceToken | null {
|
||||
try {
|
||||
if (!fs.existsSync(DEVICE_TOKEN_FILE)) return null
|
||||
const data = JSON.parse(fs.readFileSync(DEVICE_TOKEN_FILE, 'utf-8')) as StoredDeviceToken
|
||||
if (!data.token || !data.deviceId) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredDeviceToken(entry: StoredDeviceToken) {
|
||||
ensureDataDir()
|
||||
fs.writeFileSync(DEVICE_TOKEN_FILE, JSON.stringify(entry, null, 2), { mode: 0o600 })
|
||||
try {
|
||||
fs.chmodSync(DEVICE_TOKEN_FILE, 0o600)
|
||||
} catch {
|
||||
// ponytail: chmod best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredDeviceToken() {
|
||||
try {
|
||||
if (fs.existsSync(DEVICE_TOKEN_FILE)) fs.unlinkSync(DEVICE_TOKEN_FILE)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import type {
|
||||
EventFrame,
|
||||
ChatEvent,
|
||||
AgentEvent,
|
||||
ExecStartedEvent,
|
||||
ExecOutputEvent,
|
||||
ExecCompletedEvent,
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecEvent,
|
||||
SessionInfo,
|
||||
} from './protocol'
|
||||
import { parseSessionKey } from './protocol'
|
||||
@@ -18,6 +22,7 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
|
||||
isGroup: parsed.isGroup,
|
||||
lastActivityAt: info.lastActivityAt,
|
||||
status: 'idle',
|
||||
spawnedBy: info.spawnedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +55,13 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.message) {
|
||||
// v4: deltaText when replace===true or message absent; else cumulative message path
|
||||
if (
|
||||
typeof event.deltaText === 'string' &&
|
||||
(event.replace === true || event.message == null)
|
||||
) {
|
||||
action.content = event.deltaText
|
||||
} else if (event.message) {
|
||||
if (typeof event.message === 'string') {
|
||||
action.content = event.message
|
||||
} else if (typeof event.message === 'object') {
|
||||
@@ -108,11 +119,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') {
|
||||
@@ -123,7 +134,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 || '')
|
||||
}
|
||||
@@ -147,7 +159,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
|
||||
@@ -168,12 +184,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),
|
||||
@@ -185,8 +196,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
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
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
|
||||
startedAt: number | null
|
||||
}
|
||||
|
||||
class PersistenceService {
|
||||
private sessions: Map<string, MonitorSession> = new Map()
|
||||
private actions: MonitorAction[] = []
|
||||
private execEvents: MonitorExecEvent[] = []
|
||||
private enabled = false
|
||||
private startedAt: number | null = null
|
||||
|
||||
constructor() {
|
||||
this.ensureDataDir()
|
||||
this.loadState()
|
||||
this.loadData()
|
||||
// Auto-start by default if no state file exists
|
||||
if (!this.enabled && !fs.existsSync(STATE_FILE)) {
|
||||
this.start()
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadState() {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PersistenceState
|
||||
this.enabled = data.enabled
|
||||
this.startedAt = data.startedAt
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveState() {
|
||||
const state: PersistenceState = {
|
||||
enabled: this.enabled,
|
||||
startedAt: this.startedAt,
|
||||
}
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
|
||||
}
|
||||
|
||||
private loadData() {
|
||||
// Load sessions
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')) as MonitorSession[]
|
||||
for (const session of data) {
|
||||
this.sessions.set(session.key, session)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Load actions (JSONL)
|
||||
try {
|
||||
if (fs.existsSync(ACTIONS_FILE)) {
|
||||
const content = fs.readFileSync(ACTIONS_FILE, 'utf-8')
|
||||
const lines = content.trim().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const action = JSON.parse(line) as MonitorAction
|
||||
this.actions.push(action)
|
||||
} catch {
|
||||
// skip bad lines
|
||||
}
|
||||
}
|
||||
// Trim to max if needed
|
||||
if (this.actions.length > MAX_ACTIONS) {
|
||||
this.actions = this.actions.slice(-MAX_ACTIONS)
|
||||
this.saveActions()
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
const data = Array.from(this.sessions.values())
|
||||
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
private saveActions() {
|
||||
const content = this.actions.map((a) => JSON.stringify(a)).join('\n')
|
||||
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
|
||||
}
|
||||
|
||||
start(): { enabled: boolean; startedAt: number } {
|
||||
this.enabled = true
|
||||
this.startedAt = Date.now()
|
||||
this.saveState()
|
||||
console.log('[persistence] started')
|
||||
return { enabled: true, startedAt: this.startedAt }
|
||||
}
|
||||
|
||||
stop(): { enabled: boolean } {
|
||||
this.enabled = false
|
||||
this.startedAt = null
|
||||
this.saveState()
|
||||
console.log('[persistence] stopped')
|
||||
return { enabled: false }
|
||||
}
|
||||
|
||||
getStatus(): {
|
||||
enabled: boolean
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
upsertSession(session: MonitorSession) {
|
||||
if (!this.enabled) return
|
||||
this.sessions.set(session.key, session)
|
||||
this.saveSessions()
|
||||
}
|
||||
|
||||
addAction(action: MonitorAction) {
|
||||
if (!this.enabled) return
|
||||
|
||||
// Check if action already exists (by id)
|
||||
const existingIdx = this.actions.findIndex((a) => a.id === action.id)
|
||||
if (existingIdx >= 0) {
|
||||
// Update existing action
|
||||
this.actions[existingIdx] = action
|
||||
this.saveActions()
|
||||
} else {
|
||||
// Add new action
|
||||
this.actions.push(action)
|
||||
this.appendAction(action)
|
||||
|
||||
// Rotate if over limit
|
||||
if (this.actions.length > MAX_ACTIONS) {
|
||||
this.actions = this.actions.slice(-MAX_ACTIONS)
|
||||
this.saveActions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
console.log('[persistence] cleared all data')
|
||||
return { cleared: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: PersistenceService | null = null
|
||||
|
||||
export function getPersistenceService(): PersistenceService {
|
||||
if (!instance) {
|
||||
instance = new PersistenceService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Clawdbot Gateway Protocol v4 types
|
||||
|
||||
// Frame types
|
||||
export interface RequestFrame {
|
||||
type: 'req'
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export interface ResponseFrame {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: boolean
|
||||
payload?: unknown
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
|
||||
export interface EventFrame {
|
||||
type: 'event'
|
||||
event: string
|
||||
payload?: unknown
|
||||
seq?: number
|
||||
stateVersion?: { presence: number; health: number }
|
||||
}
|
||||
|
||||
export type GatewayFrame = RequestFrame | ResponseFrame | EventFrame
|
||||
|
||||
// Connection
|
||||
export interface ClientInfo {
|
||||
id: string
|
||||
displayName: string
|
||||
version: string
|
||||
platform: string
|
||||
mode: 'ui' | 'cli' | 'bot' | 'operator' | 'node'
|
||||
}
|
||||
|
||||
export interface ConnectParams {
|
||||
minProtocol: 3 | 4
|
||||
maxProtocol: 3 | 4
|
||||
client: ClientInfo
|
||||
auth?: { token?: string; deviceToken?: string }
|
||||
device?: ConnectDevice
|
||||
}
|
||||
|
||||
export interface ConnectChallengePayload {
|
||||
nonce: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
export interface ConnectDevice {
|
||||
id: string
|
||||
publicKey: string
|
||||
signature: string
|
||||
signedAt: number
|
||||
nonce: string
|
||||
}
|
||||
|
||||
export interface HelloAuth {
|
||||
role?: string
|
||||
scopes?: string[]
|
||||
deviceToken?: string
|
||||
}
|
||||
|
||||
export interface HelloOk {
|
||||
type: 'hello-ok'
|
||||
protocol: number
|
||||
snapshot: {
|
||||
presence: PresenceEntry[]
|
||||
health: unknown
|
||||
stateVersion: { presence: number; health: number }
|
||||
}
|
||||
features: { methods: string[]; events: string[] }
|
||||
auth?: HelloAuth
|
||||
}
|
||||
|
||||
export interface PresenceEntry {
|
||||
key: string
|
||||
client: ClientInfo
|
||||
connectedAt: number
|
||||
}
|
||||
|
||||
// Chat events — v4 may send deltaText (incremental) and/or cumulative message
|
||||
export interface ChatEvent {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
state: 'delta' | 'final' | 'aborted' | 'error'
|
||||
message?: unknown
|
||||
deltaText?: string
|
||||
replace?: boolean
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
}
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
// Agent events
|
||||
export interface AgentEvent {
|
||||
runId: string
|
||||
seq: number
|
||||
stream: string
|
||||
ts: number
|
||||
data: Record<string, unknown>
|
||||
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
|
||||
activeMinutes?: number
|
||||
includeLastMessage?: boolean
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
key: string
|
||||
agentId: string
|
||||
createdAt: number
|
||||
lastActivityAt: number
|
||||
messageCount: number
|
||||
lastMessage?: unknown
|
||||
/** Session key of the parent session that spawned this subagent session. */
|
||||
spawnedBy?: string
|
||||
}
|
||||
|
||||
// App-level types
|
||||
export interface MonitorSession {
|
||||
key: string
|
||||
agentId: string
|
||||
platform: string
|
||||
recipient: string
|
||||
isGroup: boolean
|
||||
lastActivityAt: number
|
||||
status: 'idle' | 'active' | 'thinking'
|
||||
/** Session key of the parent session that spawned this subagent session. */
|
||||
spawnedBy?: string
|
||||
}
|
||||
|
||||
export interface MonitorAction {
|
||||
id: string
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
|
||||
eventType: 'chat' | 'agent' | 'system'
|
||||
timestamp: number
|
||||
content?: string
|
||||
toolName?: string
|
||||
toolArgs?: unknown
|
||||
// Metadata from lifecycle/chat events
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
duration?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
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
|
||||
platform: string
|
||||
recipient: string
|
||||
isGroup: boolean
|
||||
} {
|
||||
// Format: "agent:main:discord:channel:1234567890"
|
||||
// Or: "agent:main:telegram:group:12345"
|
||||
// Or: "agent:main:whatsapp:+1234567890"
|
||||
const parts = key.split(':')
|
||||
const agentId = parts[1] || 'unknown'
|
||||
const platform = parts[2] || 'unknown'
|
||||
// Check if 4th part indicates a type (channel, group, dm, etc)
|
||||
const hasType = ['channel', 'group', 'dm', 'thread'].includes(parts[3] || '')
|
||||
const isGroup = parts[3] === 'group' || parts[3] === 'channel'
|
||||
const recipient = hasType ? parts.slice(3).join(':') : parts.slice(3).join(':')
|
||||
|
||||
return { agentId, platform, recipient, isGroup }
|
||||
}
|
||||
|
||||
export type CreateConnectOptions = {
|
||||
token?: string
|
||||
deviceToken?: string
|
||||
device?: ConnectDevice
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export function createConnectParams(
|
||||
tokenOrOpts?: string | CreateConnectOptions,
|
||||
device?: ConnectDevice
|
||||
): ConnectParams & {
|
||||
role: string
|
||||
scopes: string[]
|
||||
caps: unknown[]
|
||||
commands: unknown[]
|
||||
permissions: Record<string, unknown>
|
||||
locale: string
|
||||
userAgent: string
|
||||
} {
|
||||
// ponytail: overload keeps call sites short; prefer opts object for deviceToken/scopes
|
||||
const opts: CreateConnectOptions =
|
||||
typeof tokenOrOpts === 'string' || tokenOrOpts === undefined
|
||||
? { token: tokenOrOpts, device }
|
||||
: tokenOrOpts
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
win32: 'windows',
|
||||
darwin: 'macos',
|
||||
linux: 'linux',
|
||||
}
|
||||
const platform = platformMap[process.platform] ?? process.platform
|
||||
|
||||
const authToken = opts.token
|
||||
const authDeviceToken = opts.deviceToken
|
||||
const auth =
|
||||
authToken || authDeviceToken
|
||||
? {
|
||||
...(authToken ? { token: authToken } : {}),
|
||||
...(authDeviceToken ? { deviceToken: authDeviceToken } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
minProtocol: 4,
|
||||
maxProtocol: 4,
|
||||
client: {
|
||||
id: 'cli',
|
||||
displayName: 'crabwalk-monitor',
|
||||
version: '0.1.0',
|
||||
platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: opts.scopes ?? ['operator.read'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
locale: 'en-US',
|
||||
userAgent: 'crabwalk-monitor/0.1.0',
|
||||
auth,
|
||||
device: opts.device,
|
||||
}
|
||||
}
|
||||
+213
-13
@@ -2,13 +2,27 @@ 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 { getClawdbotClient, getClawdbotEndpoint } 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,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
createFile,
|
||||
pathExists,
|
||||
getDefaultWorkspacePath,
|
||||
expandTilde,
|
||||
type DirectoryEntry,
|
||||
type FileContent,
|
||||
} from '~/lib/workspace-fs'
|
||||
|
||||
// Server-side debug mode state
|
||||
let debugMode = false
|
||||
@@ -25,11 +39,16 @@ 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) {
|
||||
return { status: 'already_connected' as const }
|
||||
return {
|
||||
status: 'already_connected' as const,
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}
|
||||
try {
|
||||
const hello = await client.connect()
|
||||
@@ -38,11 +57,17 @@ const clawdbotRouter = router({
|
||||
protocol: hello.protocol,
|
||||
features: hello.features,
|
||||
presenceCount: hello.snapshot?.presence?.length ?? 0,
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: error instanceof Error ? error.message : 'Connection failed',
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -58,11 +83,25 @@ const clawdbotRouter = router({
|
||||
return { connected: client.connected }
|
||||
}),
|
||||
|
||||
authStatus: publicProcedure.query(() => {
|
||||
const client = getClawdbotClient()
|
||||
return {
|
||||
connected: client.connected,
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}),
|
||||
|
||||
gatewayEndpoint: publicProcedure.query(() => {
|
||||
return { url: getClawdbotEndpoint() }
|
||||
}),
|
||||
|
||||
setDebugMode: publicProcedure
|
||||
.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 }
|
||||
}),
|
||||
|
||||
@@ -76,9 +115,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 }
|
||||
}),
|
||||
@@ -98,7 +137,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 }
|
||||
}),
|
||||
|
||||
@@ -114,29 +153,38 @@ const clawdbotRouter = router({
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const client = getClawdbotClient()
|
||||
const persistence = getPersistenceService()
|
||||
if (!client.connected) {
|
||||
return { sessions: [], error: 'Not connected' }
|
||||
return { sessions: [], error: 'Not connected', authState: client.authState }
|
||||
}
|
||||
try {
|
||||
const sessions = await client.listSessions(input)
|
||||
return {
|
||||
sessions: sessions.map(sessionInfoToMonitor),
|
||||
const monitorSessions = sessions.map(sessionInfoToMonitor)
|
||||
// Persist sessions if service is enabled
|
||||
for (const session of monitorSessions) {
|
||||
persistence.upsertSession(session)
|
||||
}
|
||||
return { sessions: monitorSessions, authState: client.authState, scopes: client.scopes }
|
||||
} catch (error) {
|
||||
return {
|
||||
sessions: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to list sessions',
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
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()
|
||||
|
||||
const unsubscribe = client.onEvent((event) => {
|
||||
// Collect raw event when log collection is enabled
|
||||
@@ -157,12 +205,21 @@ 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 })
|
||||
}
|
||||
if (parsed.action) {
|
||||
// Persist action if service is enabled
|
||||
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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -171,6 +228,148 @@ const clawdbotRouter = router({
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
// Persistence service
|
||||
persistenceStatus: publicProcedure.query(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.getStatus()
|
||||
}),
|
||||
|
||||
persistenceStart: publicProcedure.mutation(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.start()
|
||||
}),
|
||||
|
||||
persistenceStop: publicProcedure.mutation(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.stop()
|
||||
}),
|
||||
|
||||
persistenceHydrate: publicProcedure.query(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.hydrate()
|
||||
}),
|
||||
|
||||
persistenceClear: publicProcedure.mutation(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.clear()
|
||||
}),
|
||||
})
|
||||
|
||||
// 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',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Write file contents
|
||||
writeFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string(), content: z.string() }))
|
||||
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
const expandedPath = expandTilde(input.path)
|
||||
await writeFile(expandedRoot, expandedPath, input.content)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to write file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Delete file
|
||||
deleteFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
|
||||
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
const expandedPath = expandTilde(input.path)
|
||||
await deleteFile(expandedRoot, expandedPath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Create file
|
||||
createFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), fileName: z.string(), content: z.string().optional() }))
|
||||
.mutation(async ({ input }): Promise<{ success: boolean; error?: string; filePath?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
// Construct path server-side using Node.js path.join
|
||||
const path = await import('path')
|
||||
const fullPath = path.join(expandedRoot, input.fileName)
|
||||
await createFile(expandedRoot, fullPath, input.content || '')
|
||||
return { success: true, filePath: fullPath }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -188,7 +387,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,359 @@
|
||||
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 and symlink escapes
|
||||
*/
|
||||
export async function validatePath(workspaceRoot: string, targetPath: string): Promise<string> {
|
||||
// Resolve to absolute paths
|
||||
const resolvedRoot = path.resolve(workspaceRoot)
|
||||
const resolvedTarget = path.resolve(targetPath)
|
||||
|
||||
// Resolve symlinks to prevent escaping workspace via symlinked paths
|
||||
// This ensures we validate the actual filesystem location, not the symlink itself
|
||||
const realRoot = await fs.realpath(resolvedRoot)
|
||||
const realTarget = await fs.realpath(resolvedTarget)
|
||||
|
||||
// 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(realRoot) + '/'
|
||||
const normalizedTarget = normalizeForComparison(realTarget)
|
||||
|
||||
// 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(realRoot)) {
|
||||
throw new Error('Path traversal detected: target path is outside workspace root')
|
||||
}
|
||||
|
||||
return realTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists directory contents
|
||||
* Returns files and directories with their types
|
||||
*/
|
||||
export async function listDirectory(
|
||||
workspaceRoot: string,
|
||||
targetPath: string
|
||||
): Promise<DirectoryEntry[]> {
|
||||
const safePath = await 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 = await 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 === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content to a file
|
||||
* Creates the file if it doesn't exist, overwrites if it does
|
||||
*/
|
||||
export async function writeFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const safePath = await validatePath(workspaceRoot, filePath)
|
||||
|
||||
try {
|
||||
// Check if parent directory exists
|
||||
const parentDir = path.dirname(safePath)
|
||||
const parentStats = await fs.stat(parentDir)
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new Error('Parent path is not a directory')
|
||||
}
|
||||
|
||||
// Write file content
|
||||
await fs.writeFile(safePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to write file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file
|
||||
*/
|
||||
export async function deleteFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
const safePath = await 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')
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(safePath)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new file with optional content
|
||||
*/
|
||||
export async function createFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string,
|
||||
content: string = ''
|
||||
): Promise<void> {
|
||||
// Expand user home directory and resolve path
|
||||
const expandedRoot = expandTilde(workspaceRoot)
|
||||
const expandedFilePath = expandTilde(filePath)
|
||||
|
||||
// Get the parent directory path
|
||||
const parentDir = path.dirname(expandedFilePath)
|
||||
const fileName = path.basename(expandedFilePath)
|
||||
|
||||
// Validate that parent directory is within workspace root
|
||||
const safeParentPath = await validatePath(expandedRoot, parentDir)
|
||||
|
||||
// Check if file already exists
|
||||
const exists = await pathExists(expandedFilePath)
|
||||
if (exists) {
|
||||
throw new Error('File already exists')
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
try {
|
||||
const parentStats = await fs.stat(safeParentPath)
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new Error('Parent path is not a directory')
|
||||
}
|
||||
} catch (error) {
|
||||
// Preserve original error if it's already a descriptive error we threw
|
||||
if (error instanceof Error && error.message === 'Parent path is not a directory') {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
// Create the file
|
||||
try {
|
||||
await fs.writeFile(expandedFilePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
+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 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>
|
||||
|
||||
|
||||
+313
-45
@@ -1,24 +1,32 @@
|
||||
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 } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import { NavTabs } from '~/components/navigation'
|
||||
import {
|
||||
sessionsCollection,
|
||||
actionsCollection,
|
||||
execsCollection,
|
||||
upsertSession,
|
||||
addAction,
|
||||
addExecEvent,
|
||||
updateSessionStatus,
|
||||
clearCollections,
|
||||
} from '~/integrations/clawdbot'
|
||||
hydrateFromServer,
|
||||
clearCompletedExecs,
|
||||
} from '~/integrations/openclaw'
|
||||
import {
|
||||
ActionGraph,
|
||||
SessionList,
|
||||
SettingsPanel,
|
||||
StatusIndicator,
|
||||
MobileSessionDrawer,
|
||||
MobileMonitorToolbar,
|
||||
} from '~/components/monitor'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
import { useIsMobile } from '~/hooks/useIsMobile'
|
||||
|
||||
export const Route = createFileRoute('/monitor/')({
|
||||
component: MonitorPageWrapper,
|
||||
@@ -56,52 +64,140 @@ function MonitorPageWrapper() {
|
||||
|
||||
const RETRY_DELAY = 3000
|
||||
const MAX_RETRIES = 10
|
||||
const DEFAULT_GATEWAY_ENDPOINT = 'ws://127.0.0.1:18789'
|
||||
type AuthState = 'unknown' | 'authorized' | 'unpaired' | 'unauthorized' | 'degraded'
|
||||
|
||||
interface PairingState {
|
||||
requestId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
function MonitorPage() {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [authState, setAuthState] = useState<AuthState>('unknown')
|
||||
const [scopes, setScopes] = useState<string[]>([])
|
||||
const [pairing, setPairing] = useState<PairingState | null>(null)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [historicalMode, setHistoricalMode] = useState(false)
|
||||
const [debugMode, setDebugMode] = useState(false)
|
||||
const [logCollection, setLogCollection] = useState(false)
|
||||
const [logCount, setLogCount] = useState(0)
|
||||
const [gatewayEndpoint, setGatewayEndpoint] = useState(DEFAULT_GATEWAY_ENDPOINT)
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
|
||||
// Persistence service state
|
||||
const [persistenceEnabled, setPersistenceEnabled] = useState(false)
|
||||
const [persistenceStartedAt, setPersistenceStartedAt] = useState<number | null>(null)
|
||||
const [persistenceSessionCount, setPersistenceSessionCount] = useState(0)
|
||||
const [persistenceActionCount, setPersistenceActionCount] = useState(0)
|
||||
|
||||
// Sidebar collapse state - default to collapsed
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||
|
||||
// Settings panel state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Mobile state
|
||||
const isMobile = useIsMobile()
|
||||
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false)
|
||||
|
||||
// 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 on mount
|
||||
// Check connection status and persistence on mount
|
||||
useEffect(() => {
|
||||
checkStatus()
|
||||
checkAuthStatus()
|
||||
checkPersistenceStatus()
|
||||
loadGatewayEndpoint()
|
||||
}, [])
|
||||
|
||||
const loadGatewayEndpoint = async () => {
|
||||
try {
|
||||
const data = await trpc.openclaw.gatewayEndpoint.query()
|
||||
setGatewayEndpoint(data.url)
|
||||
} catch {
|
||||
// keep default
|
||||
}
|
||||
}
|
||||
|
||||
const checkPersistenceStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.status.query()
|
||||
const status = await trpc.openclaw.status.query()
|
||||
setConnected(status.connected)
|
||||
} catch {
|
||||
setConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuthStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await trpc.openclaw.authStatus.query()
|
||||
setConnected(status.connected)
|
||||
setAuthState(status.authState as AuthState)
|
||||
setScopes(status.scopes ?? [])
|
||||
setPairing(status.pairing ?? null)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const canPollSessions = useMemo(() => {
|
||||
if (!connected) return false
|
||||
if (authState === 'unpaired' || authState === 'unauthorized' || authState === 'degraded') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [connected, authState])
|
||||
|
||||
const handleConnect = async (retry = 0) => {
|
||||
setConnecting(true)
|
||||
setRetryCount(retry)
|
||||
try {
|
||||
const result = await trpc.clawdbot.connect.mutate()
|
||||
const result = await trpc.openclaw.connect.mutate()
|
||||
setAuthState((result.authState as AuthState) ?? 'unknown')
|
||||
setScopes(result.scopes ?? [])
|
||||
setPairing(result.pairing ?? null)
|
||||
if (result.status === 'connected' || result.status === 'already_connected') {
|
||||
setConnected(true)
|
||||
setRetryCount(0)
|
||||
setConnecting(false)
|
||||
await loadSessions()
|
||||
if (result.authState === 'authorized' || result.authState === 'unknown') {
|
||||
// Hydrate from persistence if enabled
|
||||
await hydrateFromPersistence()
|
||||
await loadSessions()
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -115,21 +211,47 @@ function MonitorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFromPersistence = async () => {
|
||||
try {
|
||||
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)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch (e) {
|
||||
console.error('Failed to hydrate:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.disconnect.mutate()
|
||||
await trpc.openclaw.disconnect.mutate()
|
||||
setConnected(false)
|
||||
setAuthState('unknown')
|
||||
setScopes([])
|
||||
setPairing(null)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
console.error('Disconnect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSessions = async () => {
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.sessions.query(
|
||||
const result = await trpc.openclaw.sessions.query(
|
||||
historicalMode ? { activeMinutes: 1440 } : { activeMinutes: 60 }
|
||||
)
|
||||
setAuthState((prev) => (result.authState as AuthState) ?? prev)
|
||||
setScopes((prev) => result.scopes ?? prev)
|
||||
setPairing((prev) => result.pairing ?? prev)
|
||||
|
||||
if (result.sessions) {
|
||||
for (const session of result.sessions) {
|
||||
upsertSession(session)
|
||||
@@ -138,15 +260,18 @@ function MonitorPage() {
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
}
|
||||
}
|
||||
}, [historicalMode])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await loadSessions()
|
||||
}, [historicalMode])
|
||||
await checkAuthStatus()
|
||||
if (canPollSessions) {
|
||||
await loadSessions()
|
||||
}
|
||||
}, [checkAuthStatus, canPollSessions, loadSessions])
|
||||
|
||||
const handleHistoricalModeChange = (enabled: boolean) => {
|
||||
setHistoricalMode(enabled)
|
||||
if (connected) {
|
||||
if (canPollSessions) {
|
||||
loadSessions()
|
||||
}
|
||||
}
|
||||
@@ -154,7 +279,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)
|
||||
}
|
||||
@@ -163,7 +288,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)
|
||||
@@ -172,12 +297,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)
|
||||
@@ -189,19 +314,50 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistenceStart = async () => {
|
||||
try {
|
||||
const result = await trpc.openclaw.persistenceStart.mutate()
|
||||
setPersistenceEnabled(result.enabled)
|
||||
setPersistenceStartedAt(result.startedAt)
|
||||
} catch (e) {
|
||||
console.error('Failed to start persistence:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistenceStop = async () => {
|
||||
try {
|
||||
const result = await trpc.openclaw.persistenceStop.mutate()
|
||||
setPersistenceEnabled(result.enabled)
|
||||
setPersistenceStartedAt(null)
|
||||
} catch (e) {
|
||||
console.error('Failed to stop persistence:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistenceClear = async () => {
|
||||
try {
|
||||
await trpc.openclaw.persistenceClear.mutate()
|
||||
setPersistenceSessionCount(0)
|
||||
setPersistenceActionCount(0)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
console.error('Failed to clear persistence:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll log count while collecting
|
||||
useEffect(() => {
|
||||
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
|
||||
@@ -210,6 +366,31 @@ function MonitorPage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [logCollection])
|
||||
|
||||
// Poll persistence status
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Poll lightweight auth status while connected
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
const interval = setInterval(() => {
|
||||
checkAuthStatus()
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [connected, checkAuthStatus])
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed((prev) => !prev)
|
||||
}, [])
|
||||
@@ -223,18 +404,18 @@ function MonitorPage() {
|
||||
|
||||
// Poll for sessions while connected
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
if (!canPollSessions) return
|
||||
const interval = setInterval(() => {
|
||||
loadSessions()
|
||||
}, 5000) // Poll every 5 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [connected, historicalMode])
|
||||
}, [canPollSessions, loadSessions])
|
||||
|
||||
// Subscribe to real-time events
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
if (!canPollSessions) 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)
|
||||
@@ -242,6 +423,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)
|
||||
@@ -251,7 +435,11 @@ function MonitorPage() {
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [connected])
|
||||
}, [canPollSessions])
|
||||
|
||||
const pairingHint = pairing?.requestId
|
||||
? `openclaw devices approve ${pairing.requestId}`
|
||||
: 'openclaw devices list && openclaw devices approve <requestId>'
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
|
||||
@@ -268,16 +456,13 @@ 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" />
|
||||
</div>
|
||||
<h1 className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
MONITOR
|
||||
</h1>
|
||||
</div>
|
||||
{/* Navigation tabs */}
|
||||
<NavTabs />
|
||||
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-4">
|
||||
@@ -294,15 +479,51 @@ 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)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all ${
|
||||
persistenceEnabled
|
||||
? 'bg-neon-mint/10 hover:bg-neon-mint/20'
|
||||
: 'bg-shell-800/50 hover:bg-shell-700'
|
||||
}`}
|
||||
title={persistenceEnabled ? 'Background service running' : 'Background service stopped'}
|
||||
>
|
||||
<HardDrive
|
||||
size={14}
|
||||
className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'}
|
||||
/>
|
||||
{persistenceEnabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-neon-mint animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stats display */}
|
||||
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 bg-shell-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">Sessions</span>
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase">Sessions</span>
|
||||
<span className="font-display text-sm text-neon-mint">{sessions.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">Actions</span>
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase">Actions</span>
|
||||
<span className="font-display text-sm text-neon-peach">{actions.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,6 +534,13 @@ function MonitorPage() {
|
||||
debugMode={debugMode}
|
||||
logCollection={logCollection}
|
||||
logCount={logCount}
|
||||
persistenceEnabled={persistenceEnabled}
|
||||
persistenceStartedAt={persistenceStartedAt}
|
||||
persistenceSessionCount={persistenceSessionCount}
|
||||
persistenceActionCount={persistenceActionCount}
|
||||
gatewayEndpoint={gatewayEndpoint}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
onDebugModeChange={handleDebugModeChange}
|
||||
onLogCollectionChange={handleLogCollectionChange}
|
||||
@@ -321,31 +549,71 @@ function MonitorPage() {
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onRefresh={handleRefresh}
|
||||
onPersistenceStart={handlePersistenceStart}
|
||||
onPersistenceStop={handlePersistenceStop}
|
||||
onPersistenceClear={handlePersistenceClear}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{connected && !canPollSessions && (
|
||||
<div className="px-4 py-2 border-y border-neon-peach/30 bg-neon-peach/10">
|
||||
<div className="font-console text-xs text-neon-peach">
|
||||
Authentication pending. Session polling is paused to avoid missing-scope errors.
|
||||
</div>
|
||||
<div className="font-console text-[11px] text-shell-300 mt-1">
|
||||
{pairing?.message ?? `Approve this device in OpenClaw: ${pairingHint}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={handleToggleSidebar}
|
||||
/>
|
||||
{/* Sidebar - desktop only */}
|
||||
{!isMobile && (
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={handleToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Graph area */}
|
||||
<div className="flex-1 relative">
|
||||
<div className={`flex-1 relative ${isMobile ? 'pb-20' : ''}`}>
|
||||
<ActionGraph
|
||||
sessions={sessions}
|
||||
actions={actions}
|
||||
execs={execs}
|
||||
selectedSession={selectedSession}
|
||||
onSessionSelect={setSelectedSession}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile components */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileMonitorToolbar
|
||||
onOpenDrawer={() => setSessionDrawerOpen(true)}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
connected={connected}
|
||||
connecting={connecting}
|
||||
sessionCount={sessions.length}
|
||||
actionCount={actions.length}
|
||||
completedCount={completedCount}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
/>
|
||||
<MobileSessionDrawer
|
||||
open={sessionDrawerOpen}
|
||||
onClose={() => setSessionDrawerOpen(false)}
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
Star,
|
||||
FileText,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import {
|
||||
FileTree,
|
||||
FileEditor,
|
||||
MobileBottomToolbar,
|
||||
MobileFileDrawer,
|
||||
MobilePathSheet,
|
||||
ConfirmationDialog,
|
||||
FileContextMenu,
|
||||
TrashIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
NewFileDialog,
|
||||
} from '~/components/workspace'
|
||||
import { NavTabs } from '~/components/navigation'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
import { useIsMobile } from '~/hooks/useIsMobile'
|
||||
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)
|
||||
|
||||
// Starred files state
|
||||
const [starredPaths, setStarredPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
// Mobile state
|
||||
const isMobile = useIsMobile()
|
||||
const [fileDrawerOpen, setFileDrawerOpen] = useState(false)
|
||||
const [pathSheetOpen, setPathSheetOpen] = useState(false)
|
||||
|
||||
// Root entries for FileTree
|
||||
const rootEntries = workspacePath && pathValid ? (pathCache.get(workspacePath) || []) : []
|
||||
|
||||
// Load saved path and starred files on mount
|
||||
useEffect(() => {
|
||||
const savedPath = localStorage.getItem('crabcrawl:workspacePath')
|
||||
if (savedPath) {
|
||||
setWorkspacePathInput(savedPath)
|
||||
// Auto-validate saved path
|
||||
validatePathAndSet(savedPath)
|
||||
} else {
|
||||
loadDefaultPath()
|
||||
}
|
||||
|
||||
// Load starred files
|
||||
const savedStarred = localStorage.getItem('crabcrawl:starredFiles')
|
||||
if (savedStarred) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedStarred)
|
||||
setStarredPaths(new Set(parsed))
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle starring/unstarring files
|
||||
const handleStar = useCallback((filePath: string) => {
|
||||
setStarredPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath)
|
||||
} else {
|
||||
next.add(filePath)
|
||||
}
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('crabcrawl:starredFiles', JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Delete confirmation dialog state
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
|
||||
|
||||
// New file dialog state
|
||||
const [newFileDialogOpen, setNewFileDialogOpen] = useState(false)
|
||||
const [newFileFolderPath, setNewFileFolderPath] = useState<string | null>(null)
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [contextMenuFilePath, setContextMenuFilePath] = useState<string | null>(null)
|
||||
|
||||
// Clipboard feedback state
|
||||
const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
|
||||
// Handle save file with confirmation callback
|
||||
const handleSave = useCallback(
|
||||
async (content: string, callback: (success: boolean) => void) => {
|
||||
if (!selectedPath) {
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await trpc.workspace.writeFile.mutate({
|
||||
workspaceRoot: workspacePath,
|
||||
path: selectedPath,
|
||||
content,
|
||||
})
|
||||
// Reload the file to get updated metadata
|
||||
await loadFile(selectedPath)
|
||||
// Refresh the directory to update metadata
|
||||
await handleRefresh()
|
||||
callback(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error)
|
||||
callback(false)
|
||||
}
|
||||
},
|
||||
[selectedPath, workspacePath, loadFile, handleRefresh]
|
||||
)
|
||||
|
||||
// Handle delete file
|
||||
const handleDeleteFile = useCallback(async () => {
|
||||
if (!fileToDelete) return
|
||||
|
||||
try {
|
||||
await trpc.workspace.deleteFile.mutate({
|
||||
workspaceRoot: workspacePath,
|
||||
path: fileToDelete,
|
||||
})
|
||||
|
||||
// If deleted file was selected, clear selection
|
||||
if (selectedPath === fileToDelete) {
|
||||
setSelectedPath(null)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
}
|
||||
|
||||
// Clear cache and refresh
|
||||
setPathCache(new Map())
|
||||
await handleRefresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
} finally {
|
||||
setFileToDelete(null)
|
||||
setDeleteConfirmOpen(false)
|
||||
}
|
||||
}, [fileToDelete, workspacePath, selectedPath, handleRefresh])
|
||||
|
||||
// Handle create file (uses newFileFolderPath if set)
|
||||
const handleCreateFile = useCallback(
|
||||
async (fileName: string, content: string) => {
|
||||
try {
|
||||
// If creating in a subfolder, prepend the relative path
|
||||
let finalFileName = fileName
|
||||
if (newFileFolderPath && newFileFolderPath !== workspacePath) {
|
||||
const relativePath = newFileFolderPath.replace(workspacePath + '/', '')
|
||||
finalFileName = `${relativePath}/${fileName}`
|
||||
}
|
||||
|
||||
const result = await trpc.workspace.createFile.mutate({
|
||||
workspaceRoot: workspacePath,
|
||||
fileName: finalFileName,
|
||||
content,
|
||||
})
|
||||
|
||||
if (result.success && result.filePath) {
|
||||
// Clear cache and refresh
|
||||
setPathCache(new Map())
|
||||
await handleRefresh()
|
||||
|
||||
// Select the new file
|
||||
setSelectedPath(result.filePath)
|
||||
await loadFile(result.filePath)
|
||||
} else if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create file:', error)
|
||||
throw error
|
||||
} finally {
|
||||
setNewFileFolderPath(null)
|
||||
}
|
||||
},
|
||||
[workspacePath, newFileFolderPath, loadFile, handleRefresh]
|
||||
)
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, filePath: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuFilePath(filePath)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setContextMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle create file in folder (from + button in tree)
|
||||
const handleCreateFileInFolder = useCallback((folderPath: string) => {
|
||||
setNewFileFolderPath(folderPath)
|
||||
setNewFileDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// Context menu items
|
||||
const contextMenuItems = useMemo(() => [
|
||||
{
|
||||
icon: <EditIcon size={14} />,
|
||||
label: 'Edit',
|
||||
onClick: () => {
|
||||
if (contextMenuFilePath) {
|
||||
handleSelect(contextMenuFilePath, 'file')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <CopyIcon size={14} />,
|
||||
label: copyFeedback === 'copied' ? 'Copied!' : copyFeedback === 'failed' ? 'Failed' : 'Copy Path',
|
||||
onClick: async () => {
|
||||
if (contextMenuFilePath) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contextMenuFilePath)
|
||||
setCopyFeedback('copied')
|
||||
setTimeout(() => setCopyFeedback('idle'), 2000)
|
||||
} catch {
|
||||
setCopyFeedback('failed')
|
||||
setTimeout(() => setCopyFeedback('idle'), 2000)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <TrashIcon size={14} />,
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
if (contextMenuFilePath) {
|
||||
setFileToDelete(contextMenuFilePath)
|
||||
setDeleteConfirmOpen(true)
|
||||
}
|
||||
},
|
||||
},
|
||||
], [contextMenuFilePath, handleSelect, copyFeedback])
|
||||
|
||||
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 */}
|
||||
<NavTabs />
|
||||
</div>
|
||||
|
||||
{/* Path input - desktop only */}
|
||||
<div className="hidden sm:flex relative items-center gap-2 flex-1 max-w-2xl mx-4">
|
||||
<div className="flex-1 relative">
|
||||
<FolderOpen size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={workspacePathInput}
|
||||
onChange={(e) => setWorkspacePathInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter workspace path..."
|
||||
className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={validateAndSetPath}
|
||||
disabled={pathValid && workspacePathInput === workspacePath}
|
||||
className={`px-3 py-1.5 text-sm font-display rounded-lg transition-colors shrink-0 ${
|
||||
pathValid && workspacePathInput === workspacePath
|
||||
? 'bg-shell-800 text-shell-500 cursor-default'
|
||||
: 'bg-crab-600 hover:bg-crab-500 text-white'
|
||||
}`}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Refresh button - desktop only */}
|
||||
<div className="hidden sm:flex relative items-center gap-3">
|
||||
<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 - desktop only */}
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: sidebarCollapsed ? 56 : 320 }}
|
||||
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-3 py-3 border-b border-shell-800 ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 ${sidebarCollapsed ? 'mx-auto flex-col' : ''}`}>
|
||||
{loading && !sidebarCollapsed && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setNewFileDialogOpen(true)}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
|
||||
title="New File"
|
||||
>
|
||||
<Plus size={16} className="text-shell-500 hover:text-neon-mint" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
|
||||
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft size={16} className="text-gray-400 hover:text-crab-400" />
|
||||
) : (
|
||||
<PanelLeftClose size={16} className="text-shell-500 hover:text-crab-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Starred files section */}
|
||||
{starredPaths.size > 0 && (
|
||||
<>
|
||||
{sidebarCollapsed ? (
|
||||
// Collapsed: stacked file icons
|
||||
<div className="flex flex-col items-center gap-1 py-2 border-b border-shell-800">
|
||||
{[...starredPaths].slice(0, 5).map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
className={`relative p-1.5 rounded transition-colors ${
|
||||
isSelected ? 'bg-crab-500/20' : 'hover:bg-shell-800'
|
||||
}`}
|
||||
title={fileName}
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className={isSelected ? 'text-crab-400' : 'text-shell-500'}
|
||||
/>
|
||||
<Star
|
||||
size={8}
|
||||
fill="currentColor"
|
||||
className="absolute -top-0.5 -right-0.5 text-yellow-400"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{starredPaths.size > 5 && (
|
||||
<span className="text-[11px] text-shell-500">+{starredPaths.size - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Expanded: starred files list
|
||||
<div className="border-b border-shell-800 py-2">
|
||||
{[...starredPaths].map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
className={`group flex items-center gap-2 px-4 py-1.5 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
>
|
||||
<FileText
|
||||
size={14}
|
||||
className={`shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-console text-sm truncate flex-1">
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStar(filePath)
|
||||
}}
|
||||
className="text-yellow-400 hover:text-yellow-300 shrink-0"
|
||||
title="Unstar file"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
onContextMenu={handleContextMenu}
|
||||
onCreateFile={handleCreateFileInFolder}
|
||||
/>
|
||||
) : (
|
||||
<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 && !sidebarCollapsed && (
|
||||
<div className="px-4 py-2 border-t border-shell-800">
|
||||
<p className="font-console text-[11px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={`flex-1 relative bg-shell-950 ${isMobile ? 'pb-20' : ''}`}>
|
||||
<FileEditor
|
||||
content={selectedFileContent}
|
||||
fileName={selectedFileName}
|
||||
filePath={selectedPath ?? undefined}
|
||||
fileSize={selectedFileSize}
|
||||
fileModified={selectedFileModified}
|
||||
error={fileError}
|
||||
isStarred={selectedPath ? starredPaths.has(selectedPath) : false}
|
||||
onStar={handleStar}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile components */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileBottomToolbar
|
||||
onOpenDrawer={() => setFileDrawerOpen(true)}
|
||||
onOpenPathSheet={() => setPathSheetOpen(true)}
|
||||
onRefresh={handleRefresh}
|
||||
loading={loading}
|
||||
pathValid={pathValid}
|
||||
currentPath={workspacePathInput}
|
||||
/>
|
||||
<MobileFileDrawer
|
||||
open={fileDrawerOpen}
|
||||
onClose={() => setFileDrawerOpen(false)}
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
starredPaths={starredPaths}
|
||||
workspacePath={workspacePath}
|
||||
pathValid={pathValid}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
onStar={handleStar}
|
||||
/>
|
||||
<MobilePathSheet
|
||||
open={pathSheetOpen}
|
||||
onClose={() => setPathSheetOpen(false)}
|
||||
initialPath={workspacePathInput}
|
||||
validatedPath={workspacePath}
|
||||
pathValid={pathValid}
|
||||
pathError={pathError}
|
||||
onValidate={async (path) => {
|
||||
await validatePathAndSet(path)
|
||||
return pathValid
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmationDialog
|
||||
open={deleteConfirmOpen}
|
||||
title="Delete File"
|
||||
message={`Are you sure you want to delete "${fileToDelete?.split('/').pop()}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleDeleteFile}
|
||||
onCancel={() => {
|
||||
setDeleteConfirmOpen(false)
|
||||
setFileToDelete(null)
|
||||
}}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* New file dialog */}
|
||||
<NewFileDialog
|
||||
open={newFileDialogOpen}
|
||||
onClose={() => {
|
||||
setNewFileDialogOpen(false)
|
||||
setNewFileFolderPath(null)
|
||||
}}
|
||||
onCreate={handleCreateFile}
|
||||
folderPath={newFileFolderPath ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
<FileContextMenu
|
||||
open={contextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
items={contextMenuItems}
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -433,3 +433,8 @@ code, pre {
|
||||
@apply focus:outline-none focus:border-crab-500 focus:ring-2 focus:ring-crab-500/20;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Safe area padding for iOS */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
+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