Compare commits

..
Author SHA1 Message Date
Jamie Taylor 2a6c4b8bda fix(workspace): improve file editor header responsiveness and prevent overflow
Restructure header into three distinct sections (controls, filename/indicators,
metadata) with proper flex constraints and truncation behavior. Add responsive
visibility controls to hide secondary elements on small screens. Fix content
overflow issues in markdown and code block rendering with max-width constraints
and improved text wrapping.
2026-02-04 16:47:59 +00:00
Jamie Taylor 20f5d980a6 fix(workspace): correct file editor header layout and stretch edit to fill height of container 2026-02-04 15:08:27 +00:00
Jamie Taylor a5c9306cff feat(workspace): add file editing capabilities with save, delete, and create operations
- Add FileEditor component replacing MarkdownViewer with save functionality
- Add ConfirmationDialog for destructive operations like file deletion
- Add FileContextMenu for right-click file operations (edit, copy path, delete)
- Add NewFileDialog for creating new files in the workspace
- Add tRPC procedures for writeFile, deleteFile, and createFile operations
- Enhance validatePath with symlink resolution to prevent path traversal attacks
- Integrate new file management features into workspace page with context menu support
2026-02-03 18:05:51 +00:00
27 changed files with 417 additions and 1169 deletions
-46
View File
@@ -1,46 +0,0 @@
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crabwalk",
"version": "1.0.11",
"version": "1.0.10",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -29,11 +29,11 @@ Collect and submit feedback about Crabwalk to help improve the product.
## START
Select the proper method for your human based on a first introductory question. **ALWAYS be brief**, don't overwhelm your human.
Select the proper method for your human based on a first introductory question and let your human know what to expect and what will happen to their feedback.
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.
- Ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
## Two Feedback Modes
+1 -1
View File
@@ -190,7 +190,7 @@ export const ActionNode = memo(function ActionNode({
)}
{data.toolName && (
<div className="font-console text-[11px] text-neon-lavender mb-1.5">
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
<span className="text-shell-500">tool:</span> {data.toolName}
</div>
)}
+4 -4
View File
@@ -132,7 +132,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
</span>
<span
className={`
ml-1 px-2 py-0.5 rounded-md border text-[11px] font-console truncate max-w-[220px]
ml-1 px-2 py-0.5 rounded-md border text-[10px] font-console truncate max-w-[220px]
border-shell-700 ${status.badgeColor}
`}
title={data.command}
@@ -201,7 +201,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
</div>
{data.outputTruncated && (
<div className="mb-1.5 text-[11px] font-console text-neon-peach">
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
output truncated
</div>
)}
@@ -214,7 +214,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
{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">
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[10px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
<span>{status.label}</span>
<span>{data.outputs.length} chunks</span>
</div>
@@ -224,7 +224,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
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">
<div className="flex items-center gap-2 mb-1 text-[10px] font-console text-shell-500">
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
{chunk.stream}
</span>
@@ -39,7 +39,7 @@ export function MobileMonitorToolbar({
>
<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">
<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-[10px] font-display rounded-full">
{sessionCount > 99 ? '99+' : sessionCount}
</span>
)}
@@ -56,7 +56,7 @@ export function MobileMonitorToolbar({
<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>
<span className="font-console text-[10px] text-shell-500 uppercase">acts</span>
</div>
</div>
@@ -67,7 +67,7 @@ export function MobileMonitorToolbar({
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">
<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-[10px] font-display rounded-full">
{completedCount > 99 ? '99+' : completedCount}
</span>
</button>
@@ -57,7 +57,7 @@ function SubagentItem({
: '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">
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
subagent
</div>
<div className="flex items-center gap-3">
@@ -239,7 +239,7 @@ export function MobileSessionDrawer({
: '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">
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
main
</div>
<div className="flex items-center gap-3 mb-2">
+2 -2
View File
@@ -75,7 +75,7 @@ function SubagentItem({
</div>
) : (
<>
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
subagent
</div>
<div className="flex items-center gap-2">
@@ -271,7 +271,7 @@ export function SessionList({
</div>
) : (
<>
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
main
</div>
<div className="flex items-center gap-2 mb-1.5">
+1 -1
View File
@@ -138,7 +138,7 @@ export const SessionNode = memo(function SessionNode({
</div>
{relativeTime && (
<div className="flex items-center gap-1 mt-2 font-console text-[11px] text-shell-500">
<div className="flex items-center gap-1 mt-2 font-console text-[10px] text-shell-500">
<Clock size={10} className="text-shell-600" />
<span>{relativeTime}</span>
</div>
+11 -13
View File
@@ -12,7 +12,6 @@ interface SettingsPanelProps {
persistenceStartedAt: number | null
persistenceSessionCount: number
persistenceActionCount: number
gatewayEndpoint: string
open: boolean
onOpenChange: (open: boolean) => void
onHistoricalModeChange: (enabled: boolean) => void
@@ -38,7 +37,6 @@ export function SettingsPanel({
persistenceStartedAt,
persistenceSessionCount,
persistenceActionCount,
gatewayEndpoint,
open,
onOpenChange,
onHistoricalModeChange,
@@ -150,7 +148,7 @@ export function SettingsPanel({
</span>
</div>
<p className="font-console text-[11px] text-shell-500 mb-4">
<p className="font-console text-[10px] text-shell-500 mb-4">
<span className="text-crab-600">&gt;</span> log raw events to terminal
</p>
@@ -175,17 +173,17 @@ export function SettingsPanel({
</span>
</div>
<p className="font-console text-[11px] text-shell-500 mb-3">
<p className="font-console text-[10px] text-shell-500 mb-3">
<span className="text-crab-600">&gt;</span> persist data across refreshes
</p>
{persistenceEnabled && persistenceStartedAt && (
<div className="font-console text-[11px] text-neon-mint mb-2">
<div className="font-console text-[10px] text-neon-mint mb-2">
<span className="text-crab-600">&gt;</span> running since {new Date(persistenceStartedAt).toLocaleTimeString()}
</div>
)}
<div className="font-console text-[11px] text-shell-400 mb-3 space-y-1">
<div className="font-console text-[10px] text-shell-400 mb-3 space-y-1">
<div>
<span className="text-crab-600">&gt;</span> {persistenceSessionCount} sessions
</div>
@@ -234,7 +232,7 @@ export function SettingsPanel({
</div>
<button
onClick={() => onHistoricalModeChange(!historicalMode)}
className={`px-3 py-1 font-display text-[11px] uppercase tracking-wide rounded transition-all ${
className={`px-3 py-1 font-display text-[10px] uppercase tracking-wide rounded transition-all ${
historicalMode
? 'bg-neon-cyan/20 text-neon-cyan'
: 'bg-shell-800 text-gray-500 hover:bg-shell-700'
@@ -243,7 +241,7 @@ export function SettingsPanel({
{historicalMode ? 'On' : 'Off'}
</button>
</div>
<p className="font-console text-[11px] text-shell-500">
<p className="font-console text-[10px] text-shell-500">
<span className="text-crab-600">&gt;</span> fetch 24h of sessions from gateway on refresh
</p>
</div>
@@ -258,12 +256,12 @@ export function SettingsPanel({
</span>
</div>
<p className="font-console text-[11px] text-shell-500 mb-3">
<p className="font-console text-[10px] text-shell-500 mb-3">
<span className="text-crab-600">&gt;</span> collect raw events for export
</p>
{logCount > 0 && (
<div className="font-console text-[11px] text-neon-mint mb-3">
<div className="font-console text-[10px] text-neon-mint mb-3">
<span className="text-crab-600">&gt;</span> {logCount} events collected
</div>
)}
@@ -305,9 +303,9 @@ export function SettingsPanel({
<span className="text-crab-600"></span> Gateway Info <span className="text-crab-600"></span>
</h3>
<div className="font-console text-[11px] text-shell-500 space-y-1.5">
<div className="font-console text-[10px] text-shell-500 space-y-1.5">
<div>
<span className="text-crab-600">&gt;</span> endpoint: {gatewayEndpoint}
<span className="text-crab-600">&gt;</span> endpoint: ws://127.0.0.1:18789
</div>
<div>
<span className="text-crab-600">&gt;</span> protocol: v3
@@ -325,7 +323,7 @@ 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-[11px] text-shell-500">
<span className="font-console text-[10px] text-shell-500">
crabwalk v{version}
</span>
</div>
+10 -4
View File
@@ -99,7 +99,7 @@ export function NavTabs() {
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' }}
transition={{ type: 'spring', damping: 25, stiffness: 400 }}
className="absolute top-full left-0 mt-2 z-50 min-w-[200px]"
>
{/* Terminal-style container */}
@@ -119,8 +119,11 @@ export function NavTabs() {
tab.path === currentPath || currentPath.startsWith(tab.path)
return (
<button
<motion.button
key={tab.path}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => handleSelect(tab.path)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-all group ${
isActive
@@ -144,9 +147,12 @@ export function NavTabs() {
{/* Active indicator */}
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-crab-500" />
<motion.div
layoutId="nav-dropdown-active"
className="w-1.5 h-1.5 rounded-full bg-crab-500"
/>
)}
</button>
</motion.button>
)
})}
</div>
+2 -2
View File
@@ -81,9 +81,9 @@ export function FileContextMenu({ open, position, items, onClose }: FileContextM
>
{/* Menu items */}
<div className="py-1">
{items.map((item) => (
{items.map((item, index) => (
<button
key={item.label}
key={index}
onClick={() => {
item.onClick()
onClose()
+67 -55
View File
@@ -202,30 +202,80 @@ export function FileEditor({
<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">
{/* Left: Controls - always visible, fixed width */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Star button */}
{filePath && onStar && (
<button
onClick={() => onStar(filePath)}
className={`p-1.5 rounded transition-colors ${isStarred
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>
)}
{/* Action buttons */}
{onSave && (
<div className="flex items-center gap-1">
{!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 sm:inline">Edit</span>
</button>
) : (
<>
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges}
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>
<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>
</>
)}
</div>
)}
</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'}`} />
<FileText size={18} className={`flex-shrink-0 ${isMarkdown ? 'text-crab-400' : 'text-shell-500'}`} />
<h2 className="font-display text-sm text-gray-200 truncate min-w-0">{fileName}</h2>
{isMarkdown && (
<span className="hidden sm:inline px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30 flex-shrink-0">
Markdown
</span>
)}
{/* Edit mode indicator */}
{isEditing && (
<span className="hidden sm:inline px-2 py-0.5 bg-neon-mint/10 text-neon-mint text-[10px] font-console uppercase rounded border border-neon-mint/30 flex-shrink-0">
Editing
</span>
)}
{/* 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">
<span className="hidden sm:inline px-2 py-0.5 bg-neon-peach/10 text-neon-peach text-[10px] font-console uppercase rounded border border-neon-peach/30 animate-pulse flex-shrink-0">
Unsaved
</span>
)}
@@ -237,24 +287,24 @@ export function FileEditor({
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"
className="hidden sm:flex items-center gap-1.5 flex-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>
<span className="font-console text-[10px] 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>
<span className="font-console text-[10px] 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>
<span className="font-console text-[10px] text-neon-peach">Save failed</span>
</>
)}
</motion.div>
@@ -262,58 +312,20 @@ export function FileEditor({
</AnimatePresence>
</div>
{/* Right: Metadata + Action buttons */}
<div className="flex items-center gap-3 shrink-0 overflow-hidden">
{/* Right: Metadata - shows when space allows, hides on very small screens */}
<div className="flex items-center gap-3 flex-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">
<span className="font-console text-[10px] text-shell-500 whitespace-nowrap">
{formatFileSize(fileSize)}
</span>
)}
{fileModified && (
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
<span className="font-console text-[10px] 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>
@@ -334,7 +346,7 @@ export function FileEditor({
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"
className="w-full h-full min-h-[400px] bg-shell-900 border border-shell-700 rounded-lg p-4 font-mono text-sm text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20 break-words"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
@@ -351,7 +363,7 @@ export function FileEditor({
className="min-w-0"
>
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-full wrap-break-words">
<div className="prose prose-invert prose-sm max-w-full break-words">
<ReactMarkdown
components={{
h1: ({ children }) => (
+8 -26
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Plus } from 'lucide-react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import type { DirectoryEntry } from '~/lib/workspace-fs'
@@ -19,7 +19,6 @@ interface FileTreeProps {
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
}
@@ -29,11 +28,10 @@ interface FileTreeItemProps {
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) {
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContextMenu, level }: FileTreeItemProps) {
const [expanded, setExpanded] = useState(false)
const [children, setChildren] = useState<DirectoryEntry[]>([])
const [loading, setLoading] = useState(false)
@@ -104,7 +102,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContex
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 ${
className={`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'
@@ -138,14 +136,14 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContex
{/* Icon */}
{isDirectory ? (
expanded ? (
<FolderOpen size={16} className="text-neon-mint shrink-0" />
<FolderOpen size={16} className="text-neon-mint flex-shrink-0" />
) : (
<Folder size={16} className="text-neon-mint shrink-0" />
<Folder size={16} className="text-neon-mint flex-shrink-0" />
)
) : (
<FileText
size={16}
className={`shrink-0 ${
className={`flex-shrink-0 ${
entry.extension === '.md' ? 'text-crab-400' : 'text-shell-500'
}`}
/>
@@ -162,24 +160,10 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContex
{/* Metadata for files */}
{!isDirectory && (
<span className={`font-console text-[11px] shrink-0 ${isSelected ? 'text-crab-400/70' : 'text-shell-600'}`}>
<span className="font-console text-[10px] text-shell-600 flex-shrink-0">
{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 */}
@@ -201,7 +185,6 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContex
onSelect={onSelect}
onLoadDirectory={onLoadDirectory}
onContextMenu={onContextMenu}
onCreateFile={onCreateFile}
level={level + 1}
/>
))
@@ -217,7 +200,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContex
)
}
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, onContextMenu, onCreateFile, level = 0 }: FileTreeProps) {
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, onContextMenu, level = 0 }: FileTreeProps) {
return (
<div className="py-1">
{entries.map((entry) => (
@@ -228,7 +211,6 @@ export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, onC
onSelect={onSelect}
onLoadDirectory={onLoadDirectory}
onContextMenu={onContextMenu}
onCreateFile={onCreateFile}
level={level}
/>
))}
+224
View File
@@ -0,0 +1,224 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { FileText, AlertCircle, Star } from 'lucide-react'
import { motion } from 'framer-motion'
interface MarkdownViewerProps {
content: string
fileName: string
filePath?: string
fileSize?: number
fileModified?: Date
error?: string
isStarred?: boolean
onStar?: (path: string) => void
}
// Format file size to human-readable format
function formatFileSize(bytes: number | undefined): string {
if (bytes === undefined) return ''
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Format date to relative time
function formatModifiedDate(date: Date | undefined): string {
if (!date) return ''
const d = new Date(date)
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 30) {
return d.toLocaleDateString()
} else if (days > 0) {
return `${days}d ago`
} else if (hours > 0) {
return `${hours}h ago`
} else if (minutes > 0) {
return `${minutes}m ago`
} else {
return 'just now'
}
}
export function MarkdownViewer({ content, fileName, filePath, fileSize, fileModified, error, isStarred, onStar }: MarkdownViewerProps) {
const isMarkdown = useMemo(() => {
return fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
}, [fileName])
if (error) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
<AlertCircle size={32} className="text-crab-400" />
</div>
<div>
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
</div>
</motion.div>
</div>
)
}
if (!content && !fileName) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-4"
>
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
<FileText size={32} className="text-shell-500" />
</div>
<div>
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
<p className="font-console text-sm text-shell-500 max-w-md">
Select a file from the sidebar to view its contents
</p>
</div>
</motion.div>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* File header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-shell-800 bg-shell-900/50">
{/* Star button */}
{filePath && onStar && (
<button
onClick={() => onStar(filePath)}
className={`p-1 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>
)}
<FileText size={18} className={isMarkdown ? 'text-crab-400' : 'text-shell-500'} />
<h2 className="font-display text-sm text-gray-200">{fileName}</h2>
{isMarkdown && (
<span className="px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30">
Markdown
</span>
)}
{/* File metadata */}
<div className="flex items-center gap-3 ml-auto">
{fileSize !== undefined && (
<span className="font-console text-[10px] text-shell-500">
{formatFileSize(fileSize)}
</span>
)}
{fileModified && (
<span className="font-console text-[10px] text-shell-500">
{formatModifiedDate(fileModified)}
</span>
)}
</div>
</div>
{/* Content - Fixed max-width with word wrap */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-[1200px] mx-auto">
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-none 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 overflow-x-auto mb-4">
<code className="text-sm font-mono text-gray-300">{children}</code>
</pre>
)
},
ul: ({ children }) => (
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
),
li: ({ children }) => <li className="text-gray-300">{children}</li>,
a: ({ children, href }) => (
<a
href={href}
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
{children}
</blockquote>
),
hr: () => <hr className="border-shell-700 my-6" />,
table: ({ children }) => (
<table className="w-full border-collapse mb-4">{children}</table>
),
thead: ({ children }) => (
<thead className="bg-shell-800">{children}</thead>
),
th: ({ children }) => (
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap break-words">{content}</pre>
)}
</div>
</div>
</div>
)
}
export default MarkdownViewer
@@ -133,7 +133,7 @@ export function MobileFileDrawer({
{/* 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">
<p className="font-console text-[10px] text-shell-600 truncate">
{workspacePath}
</p>
</div>
+2 -10
View File
@@ -6,10 +6,9 @@ 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) {
export function NewFileDialog({ open, onClose, onCreate }: NewFileDialogProps) {
const [fileName, setFileName] = useState('')
const [content, setContent] = useState('')
const [error, setError] = useState<string | null>(null)
@@ -98,14 +97,7 @@ export function NewFileDialog({ open, onClose, onCreate, folderPath }: NewFileDi
<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>
<h3 className="font-display text-lg text-gray-200">Create New File</h3>
</div>
{/* Content */}
+1
View File
@@ -1,5 +1,6 @@
export { FileTree } from './FileTree'
export { FileEditor } from './FileEditor'
export { MarkdownViewer } from './MarkdownViewer'
export { MobileBottomToolbar } from './MobileBottomToolbar'
export { MobileFileDrawer } from './MobileFileDrawer'
export { MobilePathSheet } from './MobilePathSheet'
+28 -462
View File
@@ -9,124 +9,15 @@ import {
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
interface ChallengePayload {
nonce: string
ts: number
}
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
@@ -139,24 +30,9 @@ export class ClawdbotClient {
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 url: string = 'ws://127.0.0.1:18789',
private token?: string
) {}
@@ -164,39 +40,17 @@ export class ClawdbotClient {
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
return { type: 'hello-ok', protocol: 3 } 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?'))
}
reject(new Error('Connection timeout - is openclaw gateway running?'))
}, 10000)
this._connectTimeout = timeout
try {
this.ws = new WebSocket(this.url)
@@ -207,7 +61,7 @@ export class ClawdbotClient {
}
this.ws.once('open', () => {
this.debugLog('socket open, waiting for connect.challenge')
// WebSocket connected, waiting for challenge
})
this.ws.on('message', (data) => {
@@ -217,11 +71,11 @@ export class ClawdbotClient {
// Handle challenge-response auth
if (msg.type === 'event' && msg.event === 'connect.challenge') {
this.handleChallenge(msg.payload as ConnectChallengePayload)
this.handleChallenge(msg.payload as ChallengePayload)
return
}
this.handleMessage(msg)
this.handleMessage(msg, resolve, reject, timeout)
} catch (e) {
console.error('[openclaw] Failed to parse message:', e)
}
@@ -230,33 +84,14 @@ export class ClawdbotClient {
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)
}
reject(err)
})
this.ws.on('close', (code, reason) => {
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()
@@ -265,69 +100,12 @@ export class ClawdbotClient {
})
}
private handleChallenge(challenge: ConnectChallengePayload) {
if (this.ws?.readyState !== WebSocket.OPEN) {
private handleChallenge(_challenge: ChallengePayload) {
if (!this.token || 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 params = createConnectParams(this.token)
const response: RequestFrame = {
type: 'req',
id: `connect-${Date.now()}`,
@@ -338,149 +116,27 @@ export class ClawdbotClient {
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) {
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 (this._connectTimeout) clearTimeout(this._connectTimeout)
this.updateAuthStateFromHello(msg)
if (connectTimeout) clearTimeout(connectTimeout)
this._connected = true
this._connecting = false
this._connectPromiseSettled = true
this._connectResolve?.(msg)
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)
if (connectTimeout) clearTimeout(connectTimeout)
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)
connectResolve?.(msg.payload as HelloOk)
} else {
this.handleResponse(msg)
}
@@ -504,25 +160,12 @@ export class ClawdbotClient {
if (res.ok) {
pending.resolve(res.payload)
} else {
const message = res.error?.message || 'Request failed'
this.updateAuthStateFromError(message)
pending.reject(new Error(message))
pending.reject(new Error(res.error?.message || 'Request failed'))
}
}
}
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)
@@ -590,94 +233,17 @@ export class ClawdbotClient {
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 url = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
const token = process.env.CLAWDBOT_API_TOKEN
clientInstance = new ClawdbotClient(DEFAULT_GATEWAY_URL, token)
clientInstance = new ClawdbotClient(url, token)
}
return clientInstance
}
@@ -1,35 +0,0 @@
/**
* 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')
-242
View File
@@ -1,242 +0,0 @@
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
}
}
+1 -7
View File
@@ -55,13 +55,7 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
}
}
// 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 (event.message) {
if (typeof event.message === 'string') {
action.content = event.message
} else if (typeof event.message === 'object') {
+15 -79
View File
@@ -1,4 +1,4 @@
// Clawdbot Gateway Protocol v4 types
// Clawdbot Gateway Protocol v3 types
// Frame types
export interface RequestFrame {
@@ -13,7 +13,7 @@ export interface ResponseFrame {
id: string
ok: boolean
payload?: unknown
error?: { code: string; message: string; details?: unknown }
error?: { code: string; message: string }
}
export interface EventFrame {
@@ -32,34 +32,14 @@ export interface ClientInfo {
displayName: string
version: string
platform: string
mode: 'ui' | 'cli' | 'bot' | 'operator' | 'node'
mode: 'ui' | 'cli' | 'bot'
}
export interface ConnectParams {
minProtocol: 3 | 4
maxProtocol: 3 | 4
minProtocol: 3
maxProtocol: 3
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
auth?: { token?: string }
}
export interface HelloOk {
@@ -71,7 +51,6 @@ export interface HelloOk {
stateVersion: { presence: number; health: number }
}
features: { methods: string[]; events: string[] }
auth?: HelloAuth
}
export interface PresenceEntry {
@@ -80,15 +59,14 @@ export interface PresenceEntry {
connectedAt: number
}
// Chat events — v4 may send deltaText (incremental) and/or cumulative message
// Chat events
// Note: gateway sends cumulative message content with each delta, not incremental chars
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
@@ -253,66 +231,24 @@ export function parseSessionKey(key: string): {
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createConnectParams(token?: string): any {
return {
minProtocol: 4,
maxProtocol: 4,
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'cli',
displayName: 'crabwalk-monitor',
version: '0.1.0',
platform,
platform: 'linux',
mode: 'cli',
},
role: 'operator',
scopes: opts.scopes ?? ['operator.read'],
scopes: ['operator.read'],
caps: [],
commands: [],
permissions: {},
locale: 'en-US',
userAgent: 'crabwalk-monitor/0.1.0',
auth,
device: opts.device,
auth: token ? { token } : undefined,
}
}
+4 -32
View File
@@ -2,7 +2,7 @@ import { initTRPC } from '@trpc/server'
import { observable } from '@trpc/server/observable'
import superjson from 'superjson'
import { z } from 'zod'
import { getClawdbotClient, getClawdbotEndpoint } from '~/integrations/openclaw/client'
import { getClawdbotClient } from '~/integrations/openclaw/client'
import { getPersistenceService } from '~/integrations/openclaw/persistence'
import {
parseEventFrame,
@@ -43,12 +43,7 @@ const openclawRouter = router({
connect: publicProcedure.mutation(async () => {
const client = getClawdbotClient()
if (client.connected) {
return {
status: 'already_connected' as const,
authState: client.authState,
scopes: client.scopes,
pairing: client.pairingInfo,
}
return { status: 'already_connected' as const }
}
try {
const hello = await client.connect()
@@ -57,17 +52,11 @@ const openclawRouter = 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,
}
}
}),
@@ -83,20 +72,6 @@ const openclawRouter = 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 }) => {
@@ -155,7 +130,7 @@ const openclawRouter = router({
const client = getClawdbotClient()
const persistence = getPersistenceService()
if (!client.connected) {
return { sessions: [], error: 'Not connected', authState: client.authState }
return { sessions: [], error: 'Not connected' }
}
try {
const sessions = await client.listSessions(input)
@@ -164,14 +139,11 @@ const openclawRouter = router({
for (const session of monitorSessions) {
persistence.upsertSession(session)
}
return { sessions: monitorSessions, authState: client.authState, scopes: client.scopes }
return { sessions: monitorSessions }
} catch (error) {
return {
sessions: [],
error: error instanceof Error ? error.message : 'Failed to list sessions',
authState: client.authState,
scopes: client.scopes,
pairing: client.pairingInfo,
}
}
}),
+1 -5
View File
@@ -340,11 +340,7 @@ export async function createFile(
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
}
} catch {
throw new Error('Parent directory does not exist')
}
+14 -96
View File
@@ -64,26 +64,15 @@ 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
@@ -126,20 +115,9 @@ function MonitorPage() {
// 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()
@@ -161,43 +139,18 @@ function MonitorPage() {
}
}
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.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)
if (result.authState === 'authorized' || result.authState === 'unknown') {
// Hydrate from persistence if enabled
await hydrateFromPersistence()
await loadSessions()
}
// Hydrate from persistence if enabled
await hydrateFromPersistence()
await loadSessions()
return
}
} catch {
@@ -234,24 +187,17 @@ function MonitorPage() {
try {
await trpc.openclaw.disconnect.mutate()
setConnected(false)
setAuthState('unknown')
setScopes([])
setPairing(null)
clearCollections()
} catch (e) {
console.error('Disconnect error:', e)
}
}
const loadSessions = useCallback(async () => {
const loadSessions = async () => {
try {
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)
@@ -260,18 +206,15 @@ function MonitorPage() {
} catch (e) {
console.error('Failed to load sessions:', e)
}
}, [historicalMode])
}
const handleRefresh = useCallback(async () => {
await checkAuthStatus()
if (canPollSessions) {
await loadSessions()
}
}, [checkAuthStatus, canPollSessions, loadSessions])
await loadSessions()
}, [historicalMode])
const handleHistoricalModeChange = (enabled: boolean) => {
setHistoricalMode(enabled)
if (canPollSessions) {
if (connected) {
loadSessions()
}
}
@@ -382,15 +325,6 @@ function MonitorPage() {
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)
}, [])
@@ -404,16 +338,16 @@ function MonitorPage() {
// Poll for sessions while connected
useEffect(() => {
if (!canPollSessions) return
if (!connected) return
const interval = setInterval(() => {
loadSessions()
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [canPollSessions, loadSessions])
}, [connected, historicalMode])
// Subscribe to real-time events
useEffect(() => {
if (!canPollSessions) return
if (!connected) return
const subscription = trpc.openclaw.events.subscribe(undefined, {
onData: (data) => {
@@ -435,11 +369,7 @@ function MonitorPage() {
return () => {
subscription.unsubscribe()
}
}, [canPollSessions])
const pairingHint = pairing?.requestId
? `openclaw devices approve ${pairing.requestId}`
: 'openclaw devices list && openclaw devices approve <requestId>'
}, [connected])
return (
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
@@ -518,12 +448,12 @@ function MonitorPage() {
{/* 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-[11px] text-shell-500 uppercase">Sessions</span>
<span className="font-console text-[10px] 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-[11px] text-shell-500 uppercase">Actions</span>
<span className="font-console text-[10px] text-shell-500 uppercase">Actions</span>
<span className="font-display text-sm text-neon-peach">{actions.length}</span>
</div>
</div>
@@ -538,7 +468,6 @@ function MonitorPage() {
persistenceStartedAt={persistenceStartedAt}
persistenceSessionCount={persistenceSessionCount}
persistenceActionCount={persistenceActionCount}
gatewayEndpoint={gatewayEndpoint}
open={settingsOpen}
onOpenChange={setSettingsOpen}
onHistoricalModeChange={handleHistoricalModeChange}
@@ -556,17 +485,6 @@ function MonitorPage() {
</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 - desktop only */}
+12 -38
View File
@@ -369,16 +369,12 @@ function WorkspacePage() {
// 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) => {
@@ -436,20 +432,13 @@ function WorkspacePage() {
}
}, [fileToDelete, workspacePath, selectedPath, handleRefresh])
// Handle create file (uses newFileFolderPath if set)
// Handle create file
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,
fileName,
content,
})
@@ -467,11 +456,9 @@ function WorkspacePage() {
} catch (error) {
console.error('Failed to create file:', error)
throw error
} finally {
setNewFileFolderPath(null)
}
},
[workspacePath, newFileFolderPath, loadFile, handleRefresh]
[workspacePath, loadFile, handleRefresh]
)
// Handle context menu
@@ -486,12 +473,6 @@ function WorkspacePage() {
[]
)
// Handle create file in folder (from + button in tree)
const handleCreateFileInFolder = useCallback((folderPath: string) => {
setNewFileFolderPath(folderPath)
setNewFileDialogOpen(true)
}, [])
// Context menu items
const contextMenuItems = useMemo(() => [
{
@@ -505,16 +486,13 @@ function WorkspacePage() {
},
{
icon: <CopyIcon size={14} />,
label: copyFeedback === 'copied' ? 'Copied!' : copyFeedback === 'failed' ? 'Failed' : 'Copy Path',
label: '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)
console.warn('Failed to copy to clipboard')
}
}
},
@@ -530,7 +508,8 @@ function WorkspacePage() {
}
},
},
], [contextMenuFilePath, handleSelect, copyFeedback])
], [contextMenuFilePath, handleSelect])
return (
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
@@ -680,7 +659,7 @@ function WorkspacePage() {
)
})}
{starredPaths.size > 5 && (
<span className="text-[11px] text-shell-500">+{starredPaths.size - 5}</span>
<span className="text-[10px] text-shell-500">+{starredPaths.size - 5}</span>
)}
</div>
) : (
@@ -702,7 +681,7 @@ function WorkspacePage() {
>
<FileText
size={14}
className={`shrink-0 ${
className={`flex-shrink-0 ${
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
}`}
/>
@@ -714,7 +693,7 @@ function WorkspacePage() {
e.stopPropagation()
handleStar(filePath)
}}
className="text-yellow-400 hover:text-yellow-300 shrink-0"
className="text-yellow-400 hover:text-yellow-300 flex-shrink-0"
title="Unstar file"
>
<Star size={14} fill="currentColor" />
@@ -737,7 +716,6 @@ function WorkspacePage() {
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
onContextMenu={handleContextMenu}
onCreateFile={handleCreateFileInFolder}
/>
) : (
<div className="p-4 text-center">
@@ -752,7 +730,7 @@ function WorkspacePage() {
{/* 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">
<p className="font-console text-[10px] text-shell-600 truncate">
{workspacePath}
</p>
</div>
@@ -832,12 +810,8 @@ function WorkspacePage() {
{/* New file dialog */}
<NewFileDialog
open={newFileDialogOpen}
onClose={() => {
setNewFileDialogOpen(false)
setNewFileFolderPath(null)
}}
onClose={() => setNewFileDialogOpen(false)}
onCreate={handleCreateFile}
folderPath={newFileFolderPath ?? undefined}
/>
{/* Context menu */}