This commit is contained in:
luccast
2026-01-25 17:47:27 -05:00
commit 58973da692
42 changed files with 6853 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Add your environment variables here
# Copy this file to .env.local and fill in your values
# Clawdbot gateway auth token
CLAWDBOT_API_TOKEN=
+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
.output/
.vinxi/
.vercel/
.netlify/
# Environment
.env
.env.*
!.env.example
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Generated
src/routeTree.gen.ts
+1
View File
@@ -0,0 +1 @@
22.12.0
+14
View File
@@ -0,0 +1,14 @@
{
"version": 1,
"projectName": "crabwalk",
"framework": "react",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "npm",
"chosenIntegrations": [
"tanstack-query",
"tanstack-db",
"trpc"
]
}
+50
View File
@@ -0,0 +1,50 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # start dev server on port 3000
npm run build # production build
npm start # run production server (.output/server/index.mjs)
```
## Architecture
Full-stack React app using TanStack Start (file-based routing, SSR).
**Stack:**
- TanStack Start + Router (file-based routing)
- tRPC (API layer w/ superjson transformer)
- TanStack Query (data fetching)
- TanStack DB (client-side reactive collections)
- Tailwind CSS v4
- React 19
**Key paths:**
- `src/routes/` - file-based routes, auto-generates `routeTree.gen`
- `src/routes/api/trpc.$.ts` - tRPC catch-all API handler
- `src/integrations/trpc/router.ts` - tRPC router definition (`appRouter`)
- `src/integrations/trpc/client.ts` - tRPC client
- `src/integrations/query/provider.tsx` - React Query provider
- `src/router.tsx` - TanStack Router config
**Path alias:** `~/` maps to `src/`
**tRPC pattern:** Add procedures to `appRouter` in `router.ts`, import `trpc` from `client.ts` to call.
**TanStack DB pattern:** Create collections, use `useLiveQuery()` for reactive reads, `createTransaction()` for writes.
## Clawdbot Monitor
Real-time agent activity monitor at `/monitor`.
**Key paths:**
- `src/integrations/clawdbot/` - 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)
**Config:** Set `CLAWDBOT_API_TOKEN` env var for gateway auth.
+24
View File
@@ -0,0 +1,24 @@
# crabwalk
A full-stack React application built with [TanStack Start](https://tanstack.com/start).
## Getting Started
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
## Scripts
- `dev` - Start development server
- `build` - Build for production
- `start` - Start production server
## Learn More
- [TanStack Start Documentation](https://tanstack.com/start)
- [TanStack Router Documentation](https://tanstack.com/router)
+112
View File
@@ -0,0 +1,112 @@
# Overview
Repo: clawdbot/clawdbot
Ref: 50b4126c79536a9645cddcfe6801916b5f6d9343
Base URL: https://codebase.md/clawdbot/clawdbot
Note: This repo is currently being built in the background.
Status: /status/clawdbot/clawdbot/50b4126c79536a9645cddcfe6801916b5f6d9343
Estimated build time: ~96 seconds.
Try again in 60 seconds.
## README Summary
Source: README.md
Title: 🦞 Clawdbot — Personal AI Assistant
EXFOLIATE! EXFOLIATE!
### Table of contents
- Models (selection + auth)
- Install (recommended)
- Quick start (TL;DR)
- Development channels
- From source (development)
- Security defaults (DM access)
- Highlights
- Star History
### Top sections
#### Models (selection + auth)
- Models config + CLI: Models - Auth profile rotation (OAuth vs API keys) + fallbacks: Model failover
#### Install (recommended)
Runtime: **Node ≥22**.
#### Quick start (TL;DR)
Runtime: **Node ≥22**.
#### Development channels
- **stable**: tagged releases (vYYYY.M.D or vYYYY.M.D-), npm dist-tag latest. - **beta**: prerelease tags (vYYYY.M.D-beta.N), npm dist-tag beta (macOS app may be missing). - **dev**: moving head of main, npm dist-tag dev (when published).
## Repository tree (depth=2)
File tree: /file_tree (supports depth, base_path, include, exclude)
```
├── AGENTS.md
├── appcast.xml
├── apps
├── assets
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── docker-compose.yml
├── docker-setup.sh
├── Dockerfile
├── docs
├── docs.acp.md
├── extensions
├── fly.toml
├── git-hooks
├── package.json
├── patches
├── pnpm-workspace.yaml
├── README.md
├── scripts
├── SECURITY.md
├── skills
├── src
├── Swabble
├── test
├── tsconfig.json
├── ui
├── vitest.config.ts
├── vitest.e2e.config.ts
├── vitest.extensions.config.ts
├── vitest.gateway.config.ts
├── vitest.live.config.ts
├── vitest.unit.config.ts
└── zizmor.yml
```
## Key files
- README: README.md
- docs/: docs/
- src/: src/
- config: Dockerfile, docker-compose.yml, fly.toml, pnpm-workspace.yaml, tsconfig.json, vitest.config.ts, vitest.e2e.config.ts, vitest.extensions.config.ts, vitest.gateway.config.ts, vitest.live.config.ts, vitest.unit.config.ts, zizmor.yml
- package.*: package.json
## How to navigate this repo
Paths below are relative to Base URL.
### Paths
- /readme
- /slice?path=src/index.ts&lines=1:40
- /manifest
- /search?query=router
- /file_tree
- /blob/{ref}/{path}
### Query params
- ext: csv of extensions (example: ts,md)
- base_path: path prefix for tree
- include/exclude: glob patterns
- depth: integer or all
- lines: true to add line numbers
- format: md or html
### Tips
- Use ?depth=all for full tree depth.
- /search combines lexical and semantic results when available.
### Examples
- /readme
- /slice?path=src/index.ts&lines=1:40
- /manifest
- /search?query=router
- /file_tree?base_path=src&depth=2
- /blob/main/src/index.ts
+4355
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "crabwalk",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@tanstack/db": "^0.5.0",
"@tanstack/react-db": "^0.1.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@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",
"react-dom": "^19.2.0",
"superjson": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4",
"ws": "^8.19.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.4.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^7.0.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { Database, Home, Menu, X, Zap } from 'lucide-react'
export function Header() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<header className="p-4 flex items-center bg-gray-800 text-white shadow-lg">
<button
onClick={() => setIsOpen(true)}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Open menu"
>
<Menu size={24} />
</button>
<h1 className="ml-4 text-xl font-semibold">
<Link to="/">
<img
src="/tanstack-logo-light.svg"
alt="TanStack Logo"
className="h-10"
/>
</Link>
</h1>
</header>
<aside
className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold">Navigation</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Close menu"
>
<X size={24} />
</button>
</div>
<nav className="flex-1 p-4 overflow-y-auto">
<Link
to="/"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<Home size={20} />
<span className="font-medium">Home</span>
</Link>
<Link
to="/demo/query"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<Database size={20} />
<span className="font-medium">TanStack Query</span>
</Link>
<Link
to="/demo/db"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<Database size={20} />
<span className="font-medium">TanStack DB</span>
</Link>
<Link
to="/demo/trpc"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<Zap size={20} />
<span className="font-medium">tRPC</span>
</Link>
</nav>
</aside>
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useMemo } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
type Node,
type Edge,
type NodeTypes,
MarkerType,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { SessionNode } from './SessionNode'
import { ActionNode } from './ActionNode'
import { layoutGraph } from '~/lib/graph-layout'
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
interface ActionGraphProps {
sessions: MonitorSession[]
actions: MonitorAction[]
selectedSession: string | null
onSessionSelect: (key: string | null) => void
}
const nodeTypes: NodeTypes = {
session: SessionNode,
action: ActionNode,
}
export function ActionGraph({
sessions,
actions,
selectedSession,
onSessionSelect,
}: ActionGraphProps) {
// Filter actions for selected session, or show all if none selected
const visibleActions = useMemo(() => {
if (!selectedSession) return actions.slice(-50) // Last 50 actions
return actions.filter((a) => a.sessionKey === selectedSession)
}, [actions, selectedSession])
// Build nodes
const rawNodes = useMemo(() => {
const nodes: Node[] = []
// Add session nodes
const visibleSessions = selectedSession
? sessions.filter((s) => s.key === selectedSession)
: sessions
for (const session of visibleSessions) {
nodes.push({
id: `session-${session.key}`,
type: 'session',
position: { x: 0, y: 0 },
data: session,
})
}
// Add action nodes
for (const action of visibleActions) {
nodes.push({
id: `action-${action.id}`,
type: 'action',
position: { x: 0, y: 0 },
data: action,
})
}
return nodes
}, [sessions, visibleActions, selectedSession])
// Build edges
const rawEdges = useMemo(() => {
const edges: Edge[] = []
// Group actions by runId
const runActions = new Map<string, MonitorAction[]>()
for (const action of visibleActions) {
const run = runActions.get(action.runId) ?? []
run.push(action)
runActions.set(action.runId, run)
}
// Connect session to first action of each run
for (const [runId, runActs] of runActions) {
const sorted = [...runActs].sort((a, b) => a.seq - b.seq)
if (sorted.length > 0) {
const first = sorted[0]
const sessionId = `session-${first.sessionKey}`
edges.push({
id: `e-session-${runId}`,
source: sessionId,
target: `action-${first.id}`,
animated: first.type === 'delta',
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: '#6b7280' },
})
}
// Connect actions in sequence
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const curr = sorted[i]
edges.push({
id: `e-${prev.id}-${curr.id}`,
source: `action-${prev.id}`,
target: `action-${curr.id}`,
animated: curr.type === 'delta',
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: '#6b7280' },
})
}
}
return edges
}, [visibleActions])
// Apply layout
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
if (rawNodes.length === 0) return { nodes: [], edges: [] }
return layoutGraph(rawNodes, rawEdges, {
direction: 'TB',
nodeWidth: 200,
nodeHeight: 80,
rankSep: 60,
nodeSep: 30,
})
}, [rawNodes, rawEdges])
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges)
// Update nodes when layout changes
useEffect(() => {
setNodes(layoutedNodes)
setEdges(layoutedEdges)
}, [layoutedNodes, layoutedEdges, setNodes, setEdges])
const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
if (node.type === 'session') {
const sessionKey = (node.data as MonitorSession).key
onSessionSelect(selectedSession === sessionKey ? null : sessionKey)
}
},
[onSessionSelect, selectedSession]
)
return (
<div className="w-full h-full bg-gray-950">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background color="#374151" gap={20} />
<Controls className="!bg-gray-800 !border-gray-700 !rounded-lg" />
<MiniMap
className="!bg-gray-800 !border-gray-700 !rounded-lg"
nodeColor={(node) => {
if (node.type === 'session') return '#06b6d4'
return '#6b7280'
}}
/>
</ReactFlow>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { memo, useState } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { motion } from 'framer-motion'
import { Loader2, CheckCircle, XCircle, Wrench, MessageSquare } from 'lucide-react'
import type { MonitorAction } from '~/integrations/clawdbot'
type ActionNodeData = MonitorAction
const typeConfig = {
delta: {
icon: Loader2,
color: 'border-blue-500 bg-blue-500/10',
iconColor: 'text-blue-400',
animate: true,
},
final: {
icon: CheckCircle,
color: 'border-green-500 bg-green-500/10',
iconColor: 'text-green-400',
animate: false,
},
aborted: {
icon: XCircle,
color: 'border-orange-500 bg-orange-500/10',
iconColor: 'text-orange-400',
animate: false,
},
error: {
icon: XCircle,
color: 'border-red-500 bg-red-500/10',
iconColor: 'text-red-400',
animate: false,
},
tool_call: {
icon: Wrench,
color: 'border-purple-500 bg-purple-500/10',
iconColor: 'text-purple-400',
animate: false,
},
tool_result: {
icon: MessageSquare,
color: 'border-cyan-500 bg-cyan-500/10',
iconColor: 'text-cyan-400',
animate: false,
},
}
export const ActionNode = memo(function ActionNode({
data,
selected,
}: NodeProps<ActionNodeData>) {
const [expanded, setExpanded] = useState(false)
const config = typeConfig[data.type]
const Icon = config.icon
const truncatedContent = data.content
? data.content.length > 60
? data.content.slice(0, 60) + '...'
: data.content
: null
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
onClick={() => setExpanded(!expanded)}
className={`
px-3 py-2 rounded-md border min-w-[160px] max-w-[280px] cursor-pointer
${config.color}
${selected ? 'ring-2 ring-white/50' : ''}
`}
>
<Handle type="target" position={Position.Top} className="!bg-gray-500 !w-2 !h-2" />
<div className="flex items-center gap-2 mb-1">
<Icon
size={14}
className={`${config.iconColor} ${config.animate ? 'animate-spin' : ''}`}
/>
<span className="text-xs font-medium text-gray-200 capitalize">
{data.type.replace('_', ' ')}
</span>
<span className="text-[10px] text-gray-500 ml-auto">
#{data.seq}
</span>
</div>
{data.toolName && (
<div className="text-xs text-purple-300 mb-1 font-mono">
{data.toolName}
</div>
)}
{truncatedContent && (
<div className="text-[11px] text-gray-300 leading-tight">
{expanded ? data.content : truncatedContent}
</div>
)}
{expanded && data.toolArgs && (
<pre className="mt-2 text-[10px] text-gray-400 bg-black/30 p-1 rounded overflow-auto max-h-32">
{JSON.stringify(data.toolArgs, null, 2)}
</pre>
)}
<Handle type="source" position={Position.Bottom} className="!bg-gray-500 !w-2 !h-2" />
</motion.div>
)
})
+126
View File
@@ -0,0 +1,126 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Filter } from 'lucide-react'
import { StatusIndicator } from './StatusIndicator'
import type { MonitorSession } from '~/integrations/clawdbot'
interface SessionListProps {
sessions: MonitorSession[]
selectedKey: string | null
onSelect: (key: string) => void
}
const platformEmoji: Record<string, string> = {
whatsapp: '💬',
telegram: '✈️',
discord: '🎮',
slack: '💼',
}
export function SessionList({ sessions, selectedKey, onSelect }: SessionListProps) {
const [filter, setFilter] = useState('')
const [platformFilter, setPlatformFilter] = useState<string | null>(null)
const platforms = [...new Set(sessions.map((s) => s.platform))]
const filteredSessions = sessions.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
})
// Sort: active first, then by lastActivityAt
const sortedSessions = [...filteredSessions].sort((a, b) => {
if (a.status !== 'idle' && b.status === 'idle') return -1
if (a.status === 'idle' && b.status !== 'idle') return 1
return b.lastActivityAt - a.lastActivityAt
})
return (
<div className="flex flex-col h-full bg-gray-900 border-r border-gray-700">
<div className="p-3 border-b border-gray-700">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-2.5 text-gray-500" />
<input
type="text"
placeholder="Filter sessions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm bg-gray-800 border border-gray-700 rounded focus:outline-none focus:border-cyan-500"
/>
</div>
{platforms.length > 1 && (
<div className="flex gap-1 mt-2 flex-wrap">
<button
onClick={() => setPlatformFilter(null)}
className={`px-2 py-0.5 text-xs rounded ${
!platformFilter ? 'bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'
}`}
>
All
</button>
{platforms.map((p) => (
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={`px-2 py-0.5 text-xs rounded ${
platformFilter === p ? 'bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'
}`}
>
{platformEmoji[p] || '📱'} {p}
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
<AnimatePresence mode="popLayout">
{sortedSessions.map((session) => (
<motion.button
key={session.key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onClick={() => onSelect(session.key)}
className={`w-full text-left p-3 border-b border-gray-800 hover:bg-gray-800 transition-colors ${
selectedKey === session.key ? 'bg-gray-800 border-l-2 border-l-cyan-500' : ''
}`}
>
<div className="flex items-center gap-2 mb-1">
<span>{platformEmoji[session.platform] || '📱'}</span>
<span className="text-sm font-medium truncate flex-1">
{session.recipient}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="truncate">{session.agentId}</span>
{session.isGroup && (
<span className="px-1 py-0.5 bg-gray-700 rounded text-[10px]">
group
</span>
)}
</div>
</motion.button>
))}
</AnimatePresence>
{sortedSessions.length === 0 && (
<div className="p-4 text-center text-gray-500 text-sm">
No sessions found
</div>
)}
</div>
<div className="p-2 border-t border-gray-700 text-xs text-gray-500 text-center">
{sessions.length} session{sessions.length !== 1 ? 's' : ''}
</div>
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { motion } from 'framer-motion'
import { MessageSquare, Users, User } from 'lucide-react'
import { StatusIndicator } from './StatusIndicator'
import type { MonitorSession } from '~/integrations/clawdbot'
type SessionNodeData = MonitorSession
const platformIcons: Record<string, string> = {
whatsapp: '💬',
telegram: '✈️',
discord: '🎮',
slack: '💼',
}
export const SessionNode = memo(function SessionNode({
data,
selected,
}: NodeProps<SessionNodeData>) {
const platformIcon = platformIcons[data.platform] ?? '📱'
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-gray-800 text-white shadow-lg
${selected ? 'border-cyan-400' : 'border-gray-600'}
${data.status === 'thinking' ? 'border-yellow-500' : ''}
`}
>
<Handle type="target" position={Position.Top} className="!bg-gray-500" />
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{platformIcon}</span>
<span className="font-semibold text-sm capitalize">{data.platform}</span>
<StatusIndicator status={data.status} size="sm" />
</div>
<div className="flex items-center gap-2 text-gray-300 text-xs">
{data.isGroup ? (
<Users size={12} className="text-gray-400" />
) : (
<User size={12} className="text-gray-400" />
)}
<span className="truncate max-w-[120px]">{data.recipient}</span>
</div>
<div className="mt-2 text-[10px] text-gray-500 truncate">
{data.agentId}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-gray-500" />
</motion.div>
)
})
+135
View File
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Settings, X, History, Wifi, WifiOff, RefreshCw } from 'lucide-react'
interface SettingsPanelProps {
connected: boolean
historicalMode: boolean
onHistoricalModeChange: (enabled: boolean) => void
onConnect: () => void
onDisconnect: () => void
onRefresh: () => void
}
export function SettingsPanel({
connected,
historicalMode,
onHistoricalModeChange,
onConnect,
onDisconnect,
onRefresh,
}: SettingsPanelProps) {
const [open, setOpen] = useState(false)
return (
<>
<button
onClick={() => setOpen(true)}
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-700 transition-colors"
>
<Settings size={18} className="text-gray-300" />
</button>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="fixed inset-0 bg-black/50 z-40"
/>
<motion.div
initial={{ opacity: 0, x: 300 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 300 }}
className="fixed right-0 top-0 h-full w-80 bg-gray-900 border-l border-gray-700 z-50 p-4"
>
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold">Settings</h2>
<button
onClick={() => setOpen(false)}
className="p-1 hover:bg-gray-800 rounded"
>
<X size={18} />
</button>
</div>
<div className="space-y-6">
{/* Connection status */}
<div className="p-3 bg-gray-800 rounded-lg">
<div className="flex items-center gap-2 mb-3">
{connected ? (
<Wifi size={16} className="text-green-400" />
) : (
<WifiOff size={16} className="text-red-400" />
)}
<span className="text-sm font-medium">
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="flex gap-2">
{connected ? (
<button
onClick={onDisconnect}
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-500 rounded"
>
Disconnect
</button>
) : (
<button
onClick={onConnect}
className="flex-1 px-3 py-1.5 text-sm bg-green-600 hover:bg-green-500 rounded"
>
Connect
</button>
)}
<button
onClick={onRefresh}
disabled={!connected}
className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw size={14} />
</button>
</div>
</div>
{/* Historical mode toggle */}
<div className="p-3 bg-gray-800 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<History size={16} className="text-gray-400" />
<span className="text-sm font-medium">Historical Mode</span>
</div>
<p className="text-xs text-gray-400 mb-3">
Load past sessions on connect
</p>
<button
onClick={() => onHistoricalModeChange(!historicalMode)}
className={`w-full px-3 py-1.5 text-sm rounded transition-colors ${
historicalMode
? 'bg-cyan-600 hover:bg-cyan-500'
: 'bg-gray-700 hover:bg-gray-600'
}`}
>
{historicalMode ? 'Enabled' : 'Disabled'}
</button>
</div>
{/* Info */}
<div className="text-xs text-gray-500 p-3 bg-gray-800/50 rounded-lg">
<p className="mb-2">
<strong>Gateway:</strong> ws://127.0.0.1:18789
</p>
<p>
<strong>Protocol:</strong> v3
</p>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
)
}
@@ -0,0 +1,42 @@
import { motion } from 'framer-motion'
interface StatusIndicatorProps {
status: 'idle' | 'active' | 'thinking'
size?: 'sm' | 'md' | 'lg'
}
const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4',
}
const statusColors = {
idle: 'bg-gray-500',
active: 'bg-green-500',
thinking: 'bg-yellow-500',
}
export function StatusIndicator({ status, size = 'md' }: StatusIndicatorProps) {
const isPulsing = status === 'active' || status === 'thinking'
return (
<div className="relative flex items-center justify-center">
<div
className={`rounded-full ${sizeClasses[size]} ${statusColors[status]}`}
/>
{isPulsing && (
<motion.div
className={`absolute rounded-full ${sizeClasses[size]} ${statusColors[status]}`}
initial={{ opacity: 0.6, scale: 1 }}
animate={{ opacity: 0, scale: 2.5 }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeOut',
}}
/>
)}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
export { ActionGraph } from './ActionGraph'
export { SessionList } from './SessionList'
export { SessionNode } from './SessionNode'
export { ActionNode } from './ActionNode'
export { StatusIndicator } from './StatusIndicator'
export { SettingsPanel } from './SettingsPanel'
+199
View File
@@ -0,0 +1,199 @@
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'
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
constructor(
private url: string = 'ws://127.0.0.1:18789',
private token?: string
) {}
get connected() {
return this._connected
}
async connect(): Promise<HelloOk> {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url)
this.ws.on('open', () => {
const params = createConnectParams(this.token)
this.ws!.send(JSON.stringify(params))
})
this.ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString())
this.handleMessage(msg, resolve, reject)
} catch (e) {
console.error('Failed to parse message:', e)
}
})
this.ws.on('error', (err) => {
console.error('WebSocket error:', err)
reject(err)
})
this.ws.on('close', () => {
this._connected = false
this.scheduleReconnect()
})
})
}
private handleMessage(
msg: GatewayFrame | HelloOk,
connectResolve?: (v: HelloOk) => void,
connectReject?: (e: Error) => void
) {
if ('type' in msg) {
switch (msg.type) {
case 'hello-ok':
this._connected = true
connectResolve?.(msg)
break
case 'res':
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
}
+54
View File
@@ -0,0 +1,54 @@
import { createCollection } from '@tanstack/db'
import type { MonitorSession, MonitorAction } from './protocol'
export const sessionsCollection = createCollection<MonitorSession>({
id: 'clawdbot-sessions',
primaryKey: 'key',
})
export const actionsCollection = createCollection<MonitorAction>({
id: 'clawdbot-actions',
primaryKey: '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, session)
} else {
sessionsCollection.insert(session)
}
}
// Helper to add action
export function addAction(action: MonitorAction) {
const existing = actionsCollection.state.get(action.id)
if (!existing) {
actionsCollection.insert(action)
}
}
// Helper to update session status
export function updateSessionStatus(
key: string,
status: MonitorSession['status']
) {
const session = sessionsCollection.state.get(key)
if (session) {
sessionsCollection.update(key, {
status,
lastActivityAt: Date.now(),
})
}
}
// 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)
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './protocol'
export * from './parser'
export * from './collections'
export { ClawdbotClient, getClawdbotClient, isChatEvent, isAgentEvent } from './client'
+126
View File
@@ -0,0 +1,126 @@
import type {
EventFrame,
ChatEvent,
AgentEvent,
MonitorSession,
MonitorAction,
SessionInfo,
} from './protocol'
import { parseSessionKey } from './protocol'
export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
const parsed = parseSessionKey(info.key)
return {
key: info.key,
agentId: parsed.agentId,
platform: parsed.platform,
recipient: parsed.recipient,
isGroup: parsed.isGroup,
lastActivityAt: info.lastActivityAt,
status: 'idle',
}
}
export function chatEventToAction(event: ChatEvent): MonitorAction {
const action: MonitorAction = {
id: `${event.runId}-${event.seq}`,
runId: event.runId,
sessionKey: event.sessionKey,
seq: event.seq,
type: event.state,
timestamp: Date.now(),
}
if (event.message) {
if (typeof event.message === 'string') {
action.content = event.message
} else if (
typeof event.message === 'object' &&
'content' in (event.message as Record<string, unknown>)
) {
const msg = event.message as Record<string, unknown>
action.content = String(msg.content || '')
// Check for tool calls in message
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
const tc = msg.tool_calls[0] as Record<string, unknown>
if (tc) {
action.type = 'tool_call'
action.toolName = String(tc.name || 'unknown')
action.toolArgs = tc.arguments
}
}
}
}
if (event.errorMessage) {
action.content = event.errorMessage
}
// Parent is previous seq in same run
if (event.seq > 0) {
action.parentId = `${event.runId}-${event.seq - 1}`
}
return action
}
export function agentEventToAction(event: AgentEvent): MonitorAction {
const data = event.data
let type: MonitorAction['type'] = 'delta'
let content: string | undefined
let toolName: string | undefined
let toolArgs: unknown | undefined
if (data.type === 'tool_use') {
type = 'tool_call'
toolName = String(data.name || 'unknown')
toolArgs = data.input
content = `Tool: ${toolName}`
} else if (data.type === 'tool_result') {
type = 'tool_result'
content = String(data.content || '')
} else if (data.type === 'text') {
type = 'delta'
content = String(data.text || '')
}
return {
id: `${event.runId}-${event.seq}`,
runId: event.runId,
sessionKey: event.stream,
seq: event.seq,
type,
timestamp: event.ts,
content,
toolName,
toolArgs,
parentId: event.seq > 0 ? `${event.runId}-${event.seq - 1}` : undefined,
}
}
export function parseEventFrame(
frame: EventFrame
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
if (frame.event === 'chat' && frame.payload) {
const chatEvent = frame.payload as ChatEvent
return {
action: chatEventToAction(chatEvent),
session: {
key: chatEvent.sessionKey,
status: chatEvent.state === 'delta' ? 'thinking' : 'active',
lastActivityAt: Date.now(),
},
}
}
if (frame.event === 'agent' && frame.payload) {
const agentEvent = frame.payload as AgentEvent
return {
action: agentEventToAction(agentEvent),
}
}
return null
}
+158
View File
@@ -0,0 +1,158 @@
// 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>
}
// 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: 'delta' | 'final' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
timestamp: number
content?: string
toolName?: string
toolArgs?: unknown
parentId?: string
}
// Utility functions
export function parseSessionKey(key: string): {
agentId: string
platform: string
recipient: string
isGroup: boolean
} {
// Format: "agent:claude:whatsapp:+1234567890"
// Or: "agent:claude:telegram:group:12345"
const parts = key.split(':')
const agentId = parts[1] || 'unknown'
const platform = parts[2] || 'unknown'
const isGroup = parts[3] === 'group'
const recipient = isGroup ? parts[4] || '' : parts.slice(3).join(':')
return { agentId, platform, recipient, isGroup }
}
export function createConnectParams(token?: string): ConnectParams {
return {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'clawdbot-control-ui',
displayName: 'Crabwalk Monitor',
version: '0.1.0',
platform: typeof window !== 'undefined' ? 'browser' : 'node',
mode: 'ui',
},
auth: token ? { token } : undefined,
}
}
+24
View File
@@ -0,0 +1,24 @@
import { useLiveQuery } from '@tanstack/react-db'
import { todosCollection } from '~/lib/demo-db'
export const dbDevtoolsPlugin = {
name: 'TanStack DB',
render: (
<div className="p-4 text-sm">
<h3 className="font-semibold mb-2">Collections</h3>
<DbInspector />
</div>
),
}
function DbInspector() {
const todos = useLiveQuery(todosCollection)
return (
<div>
<div className="text-xs text-gray-400 mb-1">todos ({todos.length})</div>
<pre className="bg-gray-800 p-2 rounded text-xs overflow-auto max-h-48">
{JSON.stringify(todos, null, 2)}
</pre>
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export const queryDevtoolsPlugin = {
name: 'TanStack Query',
render: <ReactQueryDevtools buttonPosition="bottom-right" />,
}
+29
View File
@@ -0,0 +1,29 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
export function getQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
},
},
})
}
+12
View File
@@ -0,0 +1,12 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import type { AppRouter } from './router'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
})
+129
View File
@@ -0,0 +1,129 @@
import { initTRPC } from '@trpc/server'
import { observable } from '@trpc/server/observable'
import superjson from 'superjson'
import { z } from 'zod'
import {
getClawdbotClient,
isChatEvent,
isAgentEvent,
parseEventFrame,
sessionInfoToMonitor,
type MonitorSession,
type MonitorAction,
} from '~/integrations/clawdbot'
const t = initTRPC.create({
transformer: superjson,
})
export const router = t.router
export const publicProcedure = t.procedure
// Clawdbot router
const clawdbotRouter = router({
connect: publicProcedure.mutation(async () => {
const client = getClawdbotClient()
if (client.connected) {
return { status: 'already_connected' as const }
}
try {
const hello = await client.connect()
return {
status: 'connected' as const,
protocol: hello.protocol,
features: hello.features,
presenceCount: hello.snapshot.presence.length,
}
} catch (error) {
return {
status: 'error' as const,
message: error instanceof Error ? error.message : 'Connection failed',
}
}
}),
disconnect: publicProcedure.mutation(() => {
const client = getClawdbotClient()
client.disconnect()
return { status: 'disconnected' as const }
}),
status: publicProcedure.query(() => {
const client = getClawdbotClient()
return { connected: client.connected }
}),
sessions: publicProcedure
.input(
z
.object({
limit: z.number().optional(),
activeMinutes: z.number().optional(),
agentId: z.string().optional(),
})
.optional()
)
.query(async ({ input }) => {
const client = getClawdbotClient()
if (!client.connected) {
return { sessions: [], error: 'Not connected' }
}
try {
const sessions = await client.listSessions(input)
return {
sessions: sessions.map(sessionInfoToMonitor),
}
} catch (error) {
return {
sessions: [],
error: error instanceof Error ? error.message : 'Failed to list sessions',
}
}
}),
events: publicProcedure.subscription(() => {
return observable<{
type: 'session' | 'action'
session?: Partial<MonitorSession>
action?: MonitorAction
}>((emit) => {
const client = getClawdbotClient()
const unsubscribe = client.onEvent((event) => {
const parsed = parseEventFrame(event)
if (parsed) {
if (parsed.session) {
emit.next({ type: 'session', session: parsed.session })
}
if (parsed.action) {
emit.next({ type: 'action', action: parsed.action })
}
}
})
return () => {
unsubscribe()
}
})
}),
})
export const appRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string().optional() }))
.query(({ input }) => {
return { greeting: `Hello ${input.name ?? 'World'}!` }
}),
getItems: publicProcedure.query(() => {
return [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
]
}),
clawdbot: clawdbotRouter,
})
export type AppRouter = typeof appRouter
+13
View File
@@ -0,0 +1,13 @@
import { createCollection } from '@tanstack/db'
export interface Todo {
id: string
text: string
completed: boolean
createdAt: number
}
export const todosCollection = createCollection<Todo>({
id: 'todos',
primaryKey: 'id',
})
+78
View File
@@ -0,0 +1,78 @@
import dagre from 'dagre'
import type { Node, Edge } from '@xyflow/react'
export interface LayoutOptions {
direction?: 'TB' | 'LR' | 'BT' | 'RL'
nodeWidth?: number
nodeHeight?: number
rankSep?: number
nodeSep?: number
}
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 g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
// 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 })
}
// Add edges
for (const edge of edges) {
g.setEdge(edge.source, edge.target)
}
// Run layout
dagre.layout(g)
// 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
return {
...node,
position: {
x: nodeWithPosition.x - width / 2,
y: nodeWithPosition.y - height / 2,
},
}
})
return { nodes: layoutedNodes, edges }
}
// Group nodes by session for better visual organization
export function groupNodesBySession(
nodes: Node[]
): Map<string, Node[]> {
const groups = new Map<string, Node[]>()
for (const node of nodes) {
const sessionKey = node.data?.sessionKey as string | undefined
if (sessionKey) {
const group = groups.get(sessionKey) ?? []
group.push(node)
groups.set(sessionKey, group)
}
}
return groups
}
+18
View File
@@ -0,0 +1,18 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
+46
View File
@@ -0,0 +1,46 @@
import React from 'react'
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import appCss from '../styles.css?url'
import { Header } from '../components/Header'
import { QueryProvider } from '../integrations/query/provider'
import { queryDevtoolsPlugin } from '../integrations/query/devtools'
import { dbDevtoolsPlugin } from '../integrations/db/devtools'
const devtoolsPlugins = [
queryDevtoolsPlugin,
dbDevtoolsPlugin,
]
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'TanStack Start Starter' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<QueryProvider>
<Header />
{children}
<TanStackRouterDevtools />
{devtoolsPlugins.map((plugin, i) => (
<React.Fragment key={i}>{plugin.render}</React.Fragment>
))}
</QueryProvider>
<Scripts />
</body>
</html>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '~/integrations/trpc/router'
async function handler({ request }: { request: Request }) {
return fetchRequestHandler({
endpoint: '/api/trpc',
req: request,
router: appRouter,
createContext: () => ({}),
})
}
export const Route = createFileRoute('/api/trpc/$')({
// @ts-expect-error server property not in route types yet
server: {
handlers: {
GET: handler,
POST: handler,
},
},
})
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useLiveQuery, createTransaction } from '@tanstack/react-db'
import { todosCollection, type Todo } from '~/lib/demo-db'
export const Route = createFileRoute('/demo/db')({
component: DbDemo,
})
function DbDemo() {
const [newTodo, setNewTodo] = useState('')
const todos = useLiveQuery(todosCollection)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!newTodo.trim()) return
const tx = createTransaction({
mutationFn: async () => {
// Local-only collection auto-persists
},
})
tx.mutate(() => {
todosCollection.insert({
id: crypto.randomUUID(),
text: newTodo.trim(),
completed: false,
createdAt: Date.now(),
})
})
await tx.commit()
setNewTodo('')
}
const handleToggle = async (todo: Todo) => {
const tx = createTransaction({
mutationFn: async () => {},
})
tx.mutate(() => {
todosCollection.update(todo.id, { completed: !todo.completed })
})
await tx.commit()
}
const handleDelete = async (id: string) => {
const tx = createTransaction({
mutationFn: async () => {},
})
tx.mutate(() => {
todosCollection.delete(id)
})
await tx.commit()
}
return (
<div className="min-h-screen bg-gray-900 text-white p-8">
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold mb-6">TanStack DB Demo</h1>
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new todo..."
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:border-orange-500"
/>
<button
type="submit"
className="px-4 py-2 bg-orange-600 hover:bg-orange-500 rounded-lg font-medium transition-colors"
>
Add
</button>
</div>
</form>
<div className="space-y-2">
{todos.map((todo) => (
<div
key={todo.id}
className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
className="w-5 h-5 rounded accent-orange-500"
/>
<span
className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}
>
{todo.text}
</span>
<button
onClick={() => handleDelete(todo.id)}
className="px-2 py-1 text-red-400 hover:text-red-300 text-sm"
>
Delete
</button>
</div>
))}
{todos.length === 0 && (
<p className="text-gray-500 text-center py-4">No todos yet. Add one above.</p>
)}
</div>
<div className="mt-8 bg-gray-800 rounded-lg p-4">
<h2 className="font-semibold mb-2">Database State</h2>
<pre className="text-sm text-gray-400 overflow-auto max-h-48">
{JSON.stringify(todos, null, 2)}
</pre>
</div>
<div className="mt-8">
<Link to="/" className="text-cyan-400 hover:text-cyan-300">
&larr; Back to Home
</Link>
</div>
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
export const Route = createFileRoute('/demo/query')({
component: QueryDemo,
})
function QueryDemo() {
const { data, isLoading, error } = useQuery({
queryKey: ['demo-post'],
queryFn: async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts/1')
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
},
})
return (
<div className="min-h-screen bg-gray-900 text-white p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-6">TanStack Query Demo</h1>
{isLoading && (
<div className="animate-pulse bg-gray-800 rounded-lg p-4">
Loading...
</div>
)}
{error && (
<div className="bg-red-900/50 border border-red-500 rounded-lg p-4">
Error: {error.message}
</div>
)}
{data && (
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-xl font-semibold mb-2">{data.title}</h2>
<p className="text-gray-400">{data.body}</p>
</div>
)}
<div className="mt-8">
<Link to="/" className="text-cyan-400 hover:text-cyan-300">
&larr; Back to Home
</Link>
</div>
</div>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { trpc } from '~/integrations/trpc/client'
export const Route = createFileRoute('/demo/trpc')({
component: TrpcDemo,
})
function TrpcDemo() {
const helloQuery = useQuery({
queryKey: ['trpc', 'hello'],
queryFn: () => trpc.hello.query({ name: 'TanStack' }),
})
const itemsQuery = useQuery({
queryKey: ['trpc', 'items'],
queryFn: () => trpc.getItems.query(),
})
return (
<div className="min-h-screen bg-gray-900 text-white p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-6">tRPC Demo</h1>
<div className="space-y-6">
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-2">Hello Query</h2>
{helloQuery.isLoading && (
<p className="text-gray-400">Loading...</p>
)}
{helloQuery.data && (
<p className="text-cyan-400">{helloQuery.data.greeting}</p>
)}
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-2">Items Query</h2>
{itemsQuery.isLoading && (
<p className="text-gray-400">Loading...</p>
)}
{itemsQuery.data && (
<ul className="space-y-1">
{itemsQuery.data.map((item) => (
<li key={item.id} className="text-gray-300">
{item.name}
</li>
))}
</ul>
)}
</div>
</div>
<div className="mt-8">
<Link to="/" className="text-cyan-400 hover:text-cyan-300">
&larr; Back to Home
</Link>
</div>
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Activity } from 'lucide-react'
export const Route = createFileRoute('/')({
component: Home,
})
function Home() {
return (
<div className="min-h-[calc(100vh-72px)] bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
<div className="text-center px-4">
<Activity size={64} className="mx-auto mb-6 text-cyan-400" />
<h1 className="text-5xl font-bold text-white mb-4">Crabwalk</h1>
<p className="text-xl text-gray-300 mb-8">
Clawdbot Companion Monitor
</p>
<div className="flex gap-4 justify-center flex-wrap">
<Link
to="/monitor"
className="px-6 py-3 bg-cyan-500 text-white rounded-lg font-medium hover:bg-cyan-600 transition-colors"
>
Open Monitor
</Link>
</div>
</div>
</div>
)
}
+206
View File
@@ -0,0 +1,206 @@
import { useState, useEffect, useCallback } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useLiveQuery, createTransaction } from '@tanstack/react-db'
import { Activity, ArrowLeft, Loader2 } from 'lucide-react'
import { trpc } from '~/integrations/trpc/client'
import {
sessionsCollection,
actionsCollection,
upsertSession,
addAction,
clearCollections,
type MonitorSession,
type MonitorAction,
} from '~/integrations/clawdbot'
import {
ActionGraph,
SessionList,
SettingsPanel,
StatusIndicator,
} from '~/components/monitor'
export const Route = createFileRoute('/monitor/')({
component: MonitorPage,
})
function MonitorPage() {
const [connected, setConnected] = useState(false)
const [connecting, setConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [historicalMode, setHistoricalMode] = useState(false)
const [selectedSession, setSelectedSession] = useState<string | null>(null)
// Live queries from TanStack DB collections
const sessions = useLiveQuery(sessionsCollection)
const actions = useLiveQuery(actionsCollection)
// Check connection status on mount
useEffect(() => {
checkStatus()
}, [])
const checkStatus = async () => {
try {
const status = await trpc.clawdbot.status.query()
setConnected(status.connected)
} catch {
setConnected(false)
}
}
const handleConnect = async () => {
setConnecting(true)
setError(null)
try {
const result = await trpc.clawdbot.connect.mutate()
if (result.status === 'connected' || result.status === 'already_connected') {
setConnected(true)
await loadSessions()
} else if (result.status === 'error') {
setError(result.message || 'Failed to connect')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Connection error')
} finally {
setConnecting(false)
}
}
const handleDisconnect = async () => {
try {
await trpc.clawdbot.disconnect.mutate()
setConnected(false)
clearCollections()
} catch (e) {
console.error('Disconnect error:', e)
}
}
const loadSessions = async () => {
try {
const result = await trpc.clawdbot.sessions.query(
historicalMode ? { activeMinutes: 60 } : { activeMinutes: 5 }
)
if (result.sessions) {
const tx = createTransaction({ mutationFn: async () => {} })
tx.mutate(() => {
for (const session of result.sessions) {
upsertSession(session)
}
})
await tx.commit()
}
} catch (e) {
console.error('Failed to load sessions:', e)
}
}
const handleRefresh = useCallback(async () => {
await loadSessions()
}, [historicalMode])
const handleHistoricalModeChange = (enabled: boolean) => {
setHistoricalMode(enabled)
if (connected) {
loadSessions()
}
}
// Auto-connect on mount if not connected
useEffect(() => {
if (!connected && !connecting) {
handleConnect()
}
}, [])
return (
<div className="h-screen flex flex-col bg-gray-950 text-white">
{/* Header */}
<header className="flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800">
<div className="flex items-center gap-4">
<Link
to="/"
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-2">
<Activity size={20} className="text-cyan-400" />
<h1 className="text-lg font-semibold">Clawdbot Monitor</h1>
</div>
<StatusIndicator status={connected ? 'active' : 'idle'} />
</div>
<div className="flex items-center gap-3">
{connecting && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Loader2 size={14} className="animate-spin" />
Connecting...
</div>
)}
{error && (
<div className="text-sm text-red-400 max-w-xs truncate">
{error}
</div>
)}
<div className="text-sm text-gray-500">
{sessions.length} sessions | {actions.length} actions
</div>
<SettingsPanel
connected={connected}
historicalMode={historicalMode}
onHistoricalModeChange={handleHistoricalModeChange}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onRefresh={handleRefresh}
/>
</div>
</header>
{/* Main content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<div className="w-64 flex-shrink-0">
<SessionList
sessions={sessions}
selectedKey={selectedSession}
onSelect={setSelectedSession}
/>
</div>
{/* Graph area */}
<div className="flex-1">
{sessions.length === 0 && !connecting ? (
<div className="h-full flex items-center justify-center text-gray-500">
<div className="text-center">
<Activity size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg mb-2">No active sessions</p>
<p className="text-sm">
{connected
? 'Waiting for agent activity...'
: 'Connect to clawdbot gateway to start monitoring'}
</p>
{!connected && (
<button
onClick={handleConnect}
disabled={connecting}
className="mt-4 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 rounded-lg text-sm font-medium disabled:opacity-50"
>
Connect
</button>
)}
</div>
</div>
) : (
<ActionGraph
sessions={sessions}
actions={actions}
selectedSession={selectedSession}
onSessionSelect={setSelectedSession}
/>
)}
</div>
</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
@import 'tailwindcss';
body {
@apply m-0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"paths": {
"~/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})