Compare commits

..
9 Commits
Author SHA1 Message Date
Luciano CastilloandCursor 77d88c543f feat(openclaw): support gateway protocol v4 (#68)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 20:13:37 -04:00
Jamie Taylor ea99ca93fd Bugfix/openclaw device identity auth (#62)
* fix(openclaw): support device-auth handshake for gateway 2026.2.14+

* added agents.md for cross-agent compatibility
2026-02-19 13:35:52 -05:00
luccast 6ca27b1e81 chore: bump version to 1.0.11 2026-02-05 14:08:13 -05:00
luccast 6a60c6e9cd fix(nav): remove redundant animations causing flicker
- remove double opacity animation on menu items
- remove slide-in stagger animation
- remove bouncy spring, use smooth easeOut
- remove layoutId animation on active indicator
2026-02-05 12:27:29 -05:00
Luciano Castillo 1299d415b8 feat(monitor): display configured gateway endpoint dynamically (#53) 2026-02-05 11:26:31 -05:00
Luciano Castillo 72a4917941 fix(workspace): UI/UX cleanup for file editor (#51)
* fix(workspace): address PR #47 review feedback

- Use stable keys (item.label) instead of array indices in FileContextMenu
- Add clipboard feedback showing "Copied!" or "Failed" status
- Fix empty catch block in createFile to preserve error context
- Remove duplicate empty line in workspace page
- Remove unused MarkdownViewer component (replaced by FileEditor)

* refactor(workspace): streamline FileEditor layout and enhance responsiveness

- Simplified the FileEditor component by removing unused action buttons and restructuring the layout for better clarity.
- Updated styles for indicators and buttons to improve visual consistency and responsiveness across different screen sizes.
- Adjusted text sizes for better readability in various states (editing, saving, etc.) and ensured proper flex behavior for metadata display.

* style(workspace): update text sizes for indicators in FileEditor

- Increased font sizes for various indicators (Markdown, Editing, Unsaved, Saving, Saved, Save failed) to improve readability.
- Adjusted button visibility logic to only show the Save button when there are unsaved changes, enhancing user experience.

* feat(workspace): add file creation functionality in FileTree and NewFileDialog

- Introduced a button in the FileTree component to create new files within directories.
- Updated NewFileDialog to accept an optional folderPath prop, allowing file creation in specified directories.
- Enhanced WorkspacePage to manage folder paths for new file creation, improving user experience when organizing files.

* style(workspace): update file metadata display color in FileTree

- Modified the text color for file size metadata in the FileTree component to improve visual distinction based on selection state. The color now changes to a lighter shade when the file is selected, enhancing user experience and clarity.

* refactor(workspace): remove redundant editing indicator from FileEditor

- Eliminated the "Editing" mode indicator from the FileEditor component to streamline the UI. This change enhances clarity by focusing on the unsaved changes indicator, improving the overall user experience.

* style(workspace): refine flex properties and text wrapping in FileEditor

- Updated flex properties for various elements in the FileEditor component to enhance layout consistency and responsiveness.
- Improved text wrapping behavior for indicators and content areas, ensuring better readability and visual alignment across different screen sizes.

* style(workspace): update text weight in FileEditor for improved readability

- Changed font weight to 'light' for the text in the FileEditor component, enhancing visual clarity and consistency with the overall design.

* style(workspace): adjust flex properties and text sizes in FileTree

- Updated flex properties to remove redundant 'flex-shrink-0' classes, enhancing layout consistency.
- Increased font size for file metadata display to improve readability.
- Ensured consistent styling for folder and file icons, contributing to a cleaner UI.

* style(monitor): increase font sizes for improved readability across components

- Updated font sizes from 10px to 11px in various components including ActionNode, ExecNode, MobileMonitorToolbar, and others to enhance text clarity.
- Adjusted text sizes in the SettingsPanel and SessionList for consistency and better user experience.
- Ensured uniformity in font sizes across the monitor components to maintain a cohesive design.
2026-02-04 15:22:33 -05:00
Jamie Taylor 00d88aa1a8 feat(workspace): add file editing capabilities with save, delete, and… (#47)
* 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

* fix(workspace): correct file editor header layout and stretch edit to fill height of container

* 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 13:17:47 -05:00
luccast 8dbb6802e6 docs: refine feedback-skill.md for clarity and brevity
- Updated instructions to emphasize brevity when communicating with users, ensuring they are not overwhelmed.
- Revised phrasing to enhance clarity in initiating feedback conversations, maintaining a conversational tone throughout.
2026-02-03 15:09:06 -05:00
luccast c76d7c3354 docs: update skill.md to enhance installation instructions and add optional QR code display
- Removed the installation check for `qrencode` from the main installation steps to streamline the process.
- Added a new section detailing optional installation of `qrencode` for QR code display, with platform-specific commands for macOS, Debian/Ubuntu, Fedora, and Arch.
- Improved clarity and organization of installation instructions to enhance user experience.
2026-02-03 09:09:55 -05:00
29 changed files with 2403 additions and 375 deletions
+46
View File
@@ -0,0 +1,46 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/routes/`: TanStack Start file-based routes (`/monitor`, `/workspace`, API handlers under `src/routes/api/`).
- `src/components/`: UI by domain (`monitor/`, `workspace/`, `navigation/`, `ani/`).
- `src/integrations/`: external/system integrations (`openclaw/`, `trpc/`, `query/`).
- `src/lib/`: shared utilities (graph layout, workspace FS helpers, demo data).
- `public/`: static assets (images, fonts, skill metadata).
- Runtime and packaging files: `Dockerfile`, `docker-compose.yml`, `bin/crabwalk`.
## Architecture Overview
- Stack: TanStack Start + Router, tRPC, TanStack Query/DB, ReactFlow, Tailwind v4, React 19.
- Monitor flow: OpenClaw gateway WebSocket -> server integration (`src/integrations/openclaw/`) -> tRPC router (`src/integrations/trpc/router.ts`) -> client collections/graph UI.
- API entrypoint: `src/routes/api/trpc.$.ts`; router setup in `src/router.tsx`.
## Build, Test, and Development Commands
- `npm run dev`: starts local dev server on `http://localhost:3000`.
- `npm run build`: creates production build with Vite/TanStack Start.
- `npm start`: runs the built server from `.output/server/index.mjs`.
- `docker-compose up -d`: run containerized app (set `CLAWDBOT_API_TOKEN` first).
## Coding Style & Naming Conventions
- Language: TypeScript + React function components.
- Style in current codebase: 2-space indentation, single quotes, semicolon-light formatting.
- Components/files: `PascalCase` for React components (example: `SessionNode.tsx`).
- Hooks/utilities: `camelCase` exports, hooks prefixed with `use` (example: `useIsMobile.ts`).
- Route files follow TanStack conventions, e.g. `src/routes/monitor/index.tsx`, `src/routes/api/trpc.$.ts`.
- Use path alias `~/` for imports from `src`.
## Testing Guidelines
- No dedicated automated test script is currently defined in `package.json`.
- Minimum pre-PR validation: run `npm run build`, then verify `/monitor` connectivity and `/workspace` file operations in `npm run dev`.
- If adding tests, colocate as `*.test.ts` / `*.test.tsx` near the feature and prefer fast unit tests for parsing/state logic.
## Commit & Pull Request Guidelines
- Follow Conventional Commit style used in history: `feat(scope): ...`, `fix(scope): ...`, `docs: ...`, `chore: ...`.
- Keep scopes aligned with feature areas (`monitor`, `workspace`, `nav`, `openclaw`).
- PRs should include:
- clear summary of behavior changes,
- linked issue(s) when applicable,
- screenshots/GIFs for UI changes,
- notes on env/config changes (tokens, gateway URL, workspace mounts).
## Security & Configuration Tips
- Never commit secrets. Use `.env` or runtime env vars (`CLAWDBOT_API_TOKEN`, `CLAWDBOT_URL`).
- Keep `.env.example` updated when introducing new required configuration.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crabwalk",
"version": "1.0.10",
"version": "1.0.11",
"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 and let your human know what to expect and what will happen to their feedback.
Select the proper method for your human based on a first introductory question. **ALWAYS be brief**, don't overwhelm your human.
Use language like "Let's collect feedback for Crabwalk", as opposed to pretending like you are representing Crabwalk.
- Ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
Now, ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
## Two Feedback Modes
+19 -14
View File
@@ -16,7 +16,7 @@ Real-time companion monitor for OpenClaw agents.
## Step 1: Install
```bash
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && (command -v qrencode &>/dev/null || (command -v pacman &>/dev/null && sudo pacman -S --noconfirm qrencode || command -v apt &>/dev/null && sudo apt install -y qrencode || command -v brew &>/dev/null && brew install qrencode || command -v dnf &>/dev/null && sudo dnf install -y qrencode) 2>/dev/null || true) && echo "🦀 Crabwalk ${VERSION} installed"
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && echo "🦀 Crabwalk ${VERSION} installed"
```
## Step 2: Verify
@@ -135,19 +135,6 @@ if ! echo $PATH | grep -q "$HOME/.local/bin"; then
[ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
export PATH="$HOME/.local/bin:$PATH"
fi
# Install qrencode for QR code display (optional)
if ! command -v qrencode &>/dev/null; then
if command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm qrencode 2>/dev/null || true
elif command -v apt &>/dev/null; then
sudo apt install -y qrencode 2>/dev/null || true
elif command -v brew &>/dev/null; then
brew install qrencode 2>/dev/null || true
elif command -v dnf &>/dev/null; then
sudo dnf install -y qrencode 2>/dev/null || true
fi
fi
```
## Manual Update
@@ -169,6 +156,24 @@ echo "🦀 Updated to ${VERSION}"
- **Submit Feedback**: `https://crabwalk.app/feedback-skill` — Help improve Crabwalk by sharing your experience
- **Browse Feedback**: `https://crabwalk.app/#/feedback` — See what others are saying
## Optional: QR Code Display
Install `qrencode` to display QR codes for easy mobile access:
```bash
# macOS
brew install qrencode
# Debian/Ubuntu
sudo apt install qrencode
# Fedora
sudo dnf install qrencode
# Arch
sudo pacman -S qrencode
```
---
Repository: https://github.com/luccast/crabwalk
+1 -1
View File
@@ -190,7 +190,7 @@ export const ActionNode = memo(function ActionNode({
)}
{data.toolName && (
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
<div className="font-console text-[11px] text-neon-lavender mb-1.5">
<span className="text-shell-500">tool:</span> {data.toolName}
</div>
)}
+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-[10px] font-console truncate max-w-[220px]
ml-1 px-2 py-0.5 rounded-md border text-[11px] font-console truncate max-w-[220px]
border-shell-700 ${status.badgeColor}
`}
title={data.command}
@@ -201,7 +201,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
</div>
{data.outputTruncated && (
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
<div className="mb-1.5 text-[11px] 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-[10px] 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-[11px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
<span>{status.label}</span>
<span>{data.outputs.length} chunks</span>
</div>
@@ -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-[10px] font-console text-shell-500">
<div className="flex items-center gap-2 mb-1 text-[11px] font-console text-shell-500">
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
{chunk.stream}
</span>
@@ -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-[10px] 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-[11px] 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-[10px] text-shell-500 uppercase">acts</span>
<span className="font-console text-[11px] 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-[10px] 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-[11px] 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-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
subagent
</div>
<div className="flex items-center gap-3">
@@ -239,7 +239,7 @@ export function MobileSessionDrawer({
: 'active:bg-shell-800/50 border-l-2 border-l-transparent'
}`}
>
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
main
</div>
<div className="flex items-center gap-3 mb-2">
+2 -2
View File
@@ -75,7 +75,7 @@ function SubagentItem({
</div>
) : (
<>
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
subagent
</div>
<div className="flex items-center gap-2">
@@ -271,7 +271,7 @@ export function SessionList({
</div>
) : (
<>
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
main
</div>
<div className="flex items-center gap-2 mb-1.5">
+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-[10px] text-shell-500">
<div className="flex items-center gap-1 mt-2 font-console text-[11px] text-shell-500">
<Clock size={10} className="text-shell-600" />
<span>{relativeTime}</span>
</div>
+13 -11
View File
@@ -12,6 +12,7 @@ interface SettingsPanelProps {
persistenceStartedAt: number | null
persistenceSessionCount: number
persistenceActionCount: number
gatewayEndpoint: string
open: boolean
onOpenChange: (open: boolean) => void
onHistoricalModeChange: (enabled: boolean) => void
@@ -37,6 +38,7 @@ export function SettingsPanel({
persistenceStartedAt,
persistenceSessionCount,
persistenceActionCount,
gatewayEndpoint,
open,
onOpenChange,
onHistoricalModeChange,
@@ -148,7 +150,7 @@ export function SettingsPanel({
</span>
</div>
<p className="font-console text-[10px] text-shell-500 mb-4">
<p className="font-console text-[11px] text-shell-500 mb-4">
<span className="text-crab-600">&gt;</span> log raw events to terminal
</p>
@@ -173,17 +175,17 @@ export function SettingsPanel({
</span>
</div>
<p className="font-console text-[10px] text-shell-500 mb-3">
<p className="font-console text-[11px] text-shell-500 mb-3">
<span className="text-crab-600">&gt;</span> persist data across refreshes
</p>
{persistenceEnabled && persistenceStartedAt && (
<div className="font-console text-[10px] text-neon-mint mb-2">
<div className="font-console text-[11px] text-neon-mint mb-2">
<span className="text-crab-600">&gt;</span> running since {new Date(persistenceStartedAt).toLocaleTimeString()}
</div>
)}
<div className="font-console text-[10px] text-shell-400 mb-3 space-y-1">
<div className="font-console text-[11px] text-shell-400 mb-3 space-y-1">
<div>
<span className="text-crab-600">&gt;</span> {persistenceSessionCount} sessions
</div>
@@ -232,7 +234,7 @@ export function SettingsPanel({
</div>
<button
onClick={() => onHistoricalModeChange(!historicalMode)}
className={`px-3 py-1 font-display text-[10px] uppercase tracking-wide rounded transition-all ${
className={`px-3 py-1 font-display text-[11px] uppercase tracking-wide rounded transition-all ${
historicalMode
? 'bg-neon-cyan/20 text-neon-cyan'
: 'bg-shell-800 text-gray-500 hover:bg-shell-700'
@@ -241,7 +243,7 @@ export function SettingsPanel({
{historicalMode ? 'On' : 'Off'}
</button>
</div>
<p className="font-console text-[10px] text-shell-500">
<p className="font-console text-[11px] text-shell-500">
<span className="text-crab-600">&gt;</span> fetch 24h of sessions from gateway on refresh
</p>
</div>
@@ -256,12 +258,12 @@ export function SettingsPanel({
</span>
</div>
<p className="font-console text-[10px] text-shell-500 mb-3">
<p className="font-console text-[11px] text-shell-500 mb-3">
<span className="text-crab-600">&gt;</span> collect raw events for export
</p>
{logCount > 0 && (
<div className="font-console text-[10px] text-neon-mint mb-3">
<div className="font-console text-[11px] text-neon-mint mb-3">
<span className="text-crab-600">&gt;</span> {logCount} events collected
</div>
)}
@@ -303,9 +305,9 @@ export function SettingsPanel({
<span className="text-crab-600"></span> Gateway Info <span className="text-crab-600"></span>
</h3>
<div className="font-console text-[10px] text-shell-500 space-y-1.5">
<div className="font-console text-[11px] text-shell-500 space-y-1.5">
<div>
<span className="text-crab-600">&gt;</span> endpoint: ws://127.0.0.1:18789
<span className="text-crab-600">&gt;</span> endpoint: {gatewayEndpoint}
</div>
<div>
<span className="text-crab-600">&gt;</span> protocol: v3
@@ -323,7 +325,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-[10px] text-shell-500">
<span className="font-console text-[11px] text-shell-500">
crabwalk v{version}
</span>
</div>
+4 -10
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={{ type: 'spring', damping: 25, stiffness: 400 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="absolute top-full left-0 mt-2 z-50 min-w-[200px]"
>
{/* Terminal-style container */}
@@ -119,11 +119,8 @@ export function NavTabs() {
tab.path === currentPath || currentPath.startsWith(tab.path)
return (
<motion.button
<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
@@ -147,12 +144,9 @@ export function NavTabs() {
{/* Active indicator */}
{isActive && (
<motion.div
layoutId="nav-dropdown-active"
className="w-1.5 h-1.5 rounded-full bg-crab-500"
/>
<div className="w-1.5 h-1.5 rounded-full bg-crab-500" />
)}
</motion.button>
</button>
)
})}
</div>
@@ -0,0 +1,104 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertTriangle, X, Check } from 'lucide-react'
interface ConfirmationDialogProps {
open: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
export function ConfirmationDialog({
open,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'danger',
}: ConfirmationDialogProps) {
const iconColor = variant === 'danger' ? 'text-crab-400' : variant === 'warning' ? 'text-neon-peach' : 'text-neon-cyan'
const iconBg = variant === 'danger' ? 'bg-crab-900/30' : variant === 'warning' ? 'bg-neon-peach/10' : 'bg-neon-cyan/10'
const iconBorder = variant === 'danger' ? 'border-crab-700/50' : variant === 'warning' ? 'border-neon-peach/30' : 'border-neon-cyan/30'
const confirmButtonClass = variant === 'danger'
? 'bg-crab-600 hover:bg-crab-500 text-white'
: 'bg-neon-mint hover:bg-neon-mint/90 text-shell-950'
// Handle Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
if (open) {
document.addEventListener('keydown', handleEscape)
}
return () => document.removeEventListener('keydown', handleEscape)
}, [open, onCancel])
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Dialog */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.3 }}
className="relative w-full max-w-md bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
>
{/* Header */}
<div className={`flex items-center gap-3 px-5 py-4 border-b border-shell-800 ${iconBg}`}>
<div className={`p-2 rounded-lg ${iconBg} border ${iconBorder}`}>
<AlertTriangle size={20} className={iconColor} />
</div>
<h3 className="font-display text-lg text-gray-200">{title}</h3>
</div>
{/* Content */}
<div className="px-5 py-4">
<p className="font-console text-sm text-shell-500 leading-relaxed">
{message}
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
<button
onClick={onCancel}
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
>
<X size={14} />
{cancelText}
</button>
<button
onClick={onConfirm}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-console transition-colors border ${confirmButtonClass}`}
>
<Check size={14} />
{confirmText}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
export default ConfirmationDialog
@@ -0,0 +1,151 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface ContextMenuItem {
icon: React.ReactNode
label: string
onClick: () => void
danger?: boolean
}
interface FileContextMenuProps {
open: boolean
position: { x: number; y: number } | null
items: ContextMenuItem[]
onClose: () => void
}
export function FileContextMenu({ open, position, items, onClose }: FileContextMenuProps) {
// Calculate position that stays within viewport
const adjustedPosition = React.useMemo(() => {
if (!position) return null
const menuWidth = 192 // w-48 = 12rem = 192px
const menuHeight = items.length * 42 // approximate height based on item count
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position.x
let y = position.y
// Adjust if menu would go off right edge
if (x + menuWidth + padding > viewportWidth) {
x = Math.max(padding, viewportWidth - menuWidth - padding)
}
// Adjust if menu would go off bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = Math.max(padding, viewportHeight - menuHeight - padding)
}
return { x, y }
}, [position, items.length])
// Close menu when clicking outside or pressing Escape
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handleClick = () => onClose()
if (open) {
document.addEventListener('click', handleClick)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('click', handleClick)
document.removeEventListener('keydown', handleEscape)
}
}, [open, onClose])
// Prevent clicks inside menu from closing it
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation()
}
return (
<AnimatePresence>
{open && adjustedPosition && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', duration: 0.15 }}
className="fixed z-50 bg-shell-900 rounded-lg border border-shell-700 shadow-xl overflow-hidden"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
onClick={handleMenuClick}
>
{/* Menu items */}
<div className="py-1">
{items.map((item) => (
<button
key={item.label}
onClick={() => {
item.onClick()
onClose()
}}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left font-console text-sm transition-colors ${
item.danger
? 'text-crab-400 hover:bg-crab-900/30'
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
}`}
>
<span className={item.danger ? 'text-crab-500' : 'text-shell-500'}>
{item.icon}
</span>
{item.label}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// Icon components
export const FileIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
export const TrashIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
)
export const CopyIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
export const EditIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
export const NewFileIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
export default FileContextMenu
+439
View File
@@ -0,0 +1,439 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
FileText,
Edit2,
Save,
X,
AlertCircle,
Check,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
interface FileEditorProps {
content: string
fileName: string
filePath?: string
fileSize?: number
fileModified?: Date
error?: string
isStarred?: boolean
onStar?: (path: string) => void
onSave?: (content: string, callback: (success: boolean) => void) => void
}
function formatFileSize(bytes: number | undefined): string {
if (bytes === undefined) return ''
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function formatModifiedDate(date: Date | undefined): string {
if (!date) return ''
const d = new Date(date)
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 30) {
return d.toLocaleDateString()
} else if (days > 0) {
return `${days}d ago`
} else if (hours > 0) {
return `${hours}h ago`
} else if (minutes > 0) {
return `${minutes}m ago`
} else {
return 'just now'
}
}
function Star({ size = 16, fill = 'none', className = '' }: { size?: number; fill?: string; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={fill}
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
)
}
export function FileEditor({
content,
fileName,
filePath,
fileSize,
fileModified,
error,
isStarred,
onStar,
onSave,
}: FileEditorProps) {
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState(content)
const [isSaving, setIsSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [lastSavedContent, setLastSavedContent] = useState(content)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isMarkdown = fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
// Track previous file path to detect file changes
const prevFilePathRef = useRef<string | undefined>(filePath)
// Reset edit state when file changes to prevent saving stale content
useEffect(() => {
if (filePath !== prevFilePathRef.current) {
// File changed - exit edit mode and reset buffer
setIsEditing(false)
setEditContent(content)
setLastSavedContent(content)
setSaveStatus('idle')
prevFilePathRef.current = filePath
} else if (!isEditing && content !== lastSavedContent) {
// Sync content when not editing and it changed externally
setEditContent(content)
setLastSavedContent(content)
}
}, [content, isEditing, lastSavedContent, filePath])
// Track if content has unsaved changes
const hasUnsavedChanges = editContent !== lastSavedContent
const handleSave = useCallback(() => {
if (!onSave || !hasUnsavedChanges) return
setIsSaving(true)
setSaveStatus('saving')
onSave(editContent, (success) => {
setIsSaving(false)
if (success) {
setLastSavedContent(editContent)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 2000)
} else {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 2000)
}
})
}, [editContent, hasUnsavedChanges, onSave])
const handleCancel = useCallback(() => {
setEditContent(lastSavedContent)
setIsEditing(false)
setSaveStatus('idle')
}, [lastSavedContent])
const handleKeyDown = (e: React.KeyboardEvent) => {
// Ctrl/Cmd + S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
// Escape to cancel
if (e.key === 'Escape') {
handleCancel()
}
}
// Focus textarea when entering edit mode
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus()
}
}, [isEditing])
if (error) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
<AlertCircle size={32} className="text-crab-400" />
</div>
<div>
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
</div>
</motion.div>
</div>
)
}
if (!content && !fileName) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-4"
>
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
<FileText size={32} className="text-shell-500" />
</div>
<div>
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
<p className="font-console text-sm text-shell-500 max-w-md">
Select a file from the sidebar to view its contents
</p>
</div>
</motion.div>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* File header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-shell-800 bg-shell-900/50 min-w-0">
{/* Left: Star button */}
<div className="flex items-center gap-2 shrink-0">
{filePath && onStar && (
<button
onClick={() => onStar(filePath)}
className={`p-1.5 rounded transition-colors ${isStarred
? 'text-yellow-400 hover:text-yellow-300'
: 'text-shell-600 hover:text-yellow-400'
}`}
title={isStarred ? 'Unstar file' : 'Star file'}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
</div>
{/* Middle: Filename and indicators - flexes and truncates */}
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<FileText size={18} className={`shrink-0 ${isMarkdown ? 'text-crab-400' : 'text-shell-500'}`} />
<h2 className="font-display text-sm text-gray-200 truncate min-w-0">{fileName}</h2>
{/* Unsaved changes indicator */}
{isEditing && hasUnsavedChanges && (
<span className="hidden sm:inline px-2 py-0.5 bg-neon-peach/10 text-neon-peach text-[11px] font-console uppercase rounded border border-neon-peach/30 animate-pulse shrink-0">
Unsaved
</span>
)}
{/* Save status */}
<AnimatePresence>
{isEditing && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="hidden sm:flex items-center gap-1.5 shrink-0"
>
{saveStatus === 'saving' && (
<>
<div className="w-2.5 h-2.5 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin" />
<span className="font-console text-[11px] text-neon-cyan">Saving...</span>
</>
)}
{saveStatus === 'saved' && (
<>
<Check size={12} className="text-neon-mint" />
<span className="font-console text-[11px] text-neon-mint">Saved</span>
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle size={12} className="text-neon-peach" />
<span className="font-console text-[11px] text-neon-peach">Save failed</span>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Right: Metadata + Action buttons */}
<div className="flex items-center gap-3 shrink-0 overflow-hidden">
<div className="hidden min-[480px]:flex items-center gap-3">
{fileSize !== undefined && (
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
{formatFileSize(fileSize)}
</span>
)}
{fileModified && (
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
{formatModifiedDate(fileModified)}
</span>
)}
</div>
{/* Action buttons */}
{onSave && (
<div className="flex items-center gap-2">
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
title="Edit file (or press E)"
>
<Edit2 size={14} />
<span className="hidden lg:inline">Edit</span>
</button>
) : (
<>
<button
onClick={handleCancel}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
title="Cancel (Esc)"
>
<X size={14} />
<span className="hidden sm:inline">Cancel</span>
</button>
{hasUnsavedChanges && (
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-neon-mint/10 hover:bg-neon-mint/20 rounded-lg text-sm font-console text-neon-mint transition-colors border border-neon-mint/30 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save (Ctrl+S)"
>
<Save size={14} />
<span className="hidden sm:inline">Save</span>
</button>
)}
</>
)}
</div>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-[1200px] mx-auto h-full">
<AnimatePresence mode="wait">
{isEditing ? (
<motion.div
key="editor"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full"
>
<textarea
ref={textareaRef}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full h-full min-h-[400px] bg-shell-900 border border-shell-700 rounded-lg p-4 font-mono text-sm font-light text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20 wrap-break-words"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
/>
</motion.div>
) : (
<motion.div
key="viewer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="min-w-0"
>
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-full wrap-break-words">
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
),
p: ({ children }) => (
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
),
code: ({ children, className }) => {
const isInline = !className
return isInline ? (
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
) : (
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 mb-4 max-w-full">
<code className="text-xs sm:text-sm font-mono text-gray-300 whitespace-pre-wrap break-all">{children}</code>
</pre>
)
},
ul: ({ children }) => (
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
),
li: ({ children }) => <li className="text-gray-300">{children}</li>,
a: ({ children, href }) => (
<a
href={href}
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
{children}
</blockquote>
),
hr: () => <hr className="border-shell-700 my-6" />,
table: ({ children }) => (
<table className="w-full border-collapse mb-4">{children}</table>
),
thead: ({ children }) => (
<thead className="bg-shell-800">{children}</thead>
),
th: ({ children }) => (
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<pre className="font-mono text-xs sm:text-sm text-gray-300 whitespace-pre-wrap break-all overflow-x-auto max-w-full">{content}</pre>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)
}
export default FileEditor
+40 -8
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Plus } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import type { DirectoryEntry } from '~/lib/workspace-fs'
@@ -18,6 +18,8 @@ interface FileTreeProps {
selectedPath: string | null
onSelect: (path: string, type: 'file' | 'directory') => void
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
onContextMenu?: (e: React.MouseEvent, path: string) => void
onCreateFile?: (folderPath: string) => void
level?: number
}
@@ -26,10 +28,12 @@ interface FileTreeItemProps {
selectedPath: string | null
onSelect: (path: string, type: 'file' | 'directory') => void
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
onContextMenu?: (e: React.MouseEvent, path: string) => void
onCreateFile?: (folderPath: string) => void
level: number
}
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: FileTreeItemProps) {
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContextMenu, onCreateFile, level }: FileTreeItemProps) {
const [expanded, setExpanded] = useState(false)
const [children, setChildren] = useState<DirectoryEntry[]>([])
const [loading, setLoading] = useState(false)
@@ -85,12 +89,22 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
}
}, [entry.path, entry.type, expanded, loadChildren, onSelect, isDirectory])
const handleContextMenuFn = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu && entry.type === 'file') {
onContextMenu(e, entry.path)
}
},
[entry.path, entry.type, onContextMenu]
)
return (
<div>
<motion.div
onClick={handleClick}
onContextMenu={handleContextMenuFn}
style={{ paddingLeft }}
className={`flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-1 cursor-pointer ${
className={`group flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-1 cursor-pointer ${
isSelected
? 'bg-crab-500/20 text-crab-400 border-l-2 border-crab-400'
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100 border-l-2 border-transparent'
@@ -124,14 +138,14 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
{/* Icon */}
{isDirectory ? (
expanded ? (
<FolderOpen size={16} className="text-neon-mint flex-shrink-0" />
<FolderOpen size={16} className="text-neon-mint shrink-0" />
) : (
<Folder size={16} className="text-neon-mint flex-shrink-0" />
<Folder size={16} className="text-neon-mint shrink-0" />
)
) : (
<FileText
size={16}
className={`flex-shrink-0 ${
className={`shrink-0 ${
entry.extension === '.md' ? 'text-crab-400' : 'text-shell-500'
}`}
/>
@@ -148,10 +162,24 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
{/* Metadata for files */}
{!isDirectory && (
<span className="font-console text-[10px] text-shell-600 flex-shrink-0">
<span className={`font-console text-[11px] shrink-0 ${isSelected ? 'text-crab-400/70' : 'text-shell-600'}`}>
{entry.size !== undefined && formatFileSize(entry.size)}
</span>
)}
{/* Add file button for directories */}
{isDirectory && onCreateFile && (
<button
onClick={(e) => {
e.stopPropagation()
onCreateFile(entry.path)
}}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-shell-700 rounded transition-all shrink-0"
title="New file in this folder"
>
<Plus size={14} className="text-shell-500 hover:text-neon-mint" />
</button>
)}
</motion.div>
{/* Children */}
@@ -172,6 +200,8 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
selectedPath={selectedPath}
onSelect={onSelect}
onLoadDirectory={onLoadDirectory}
onContextMenu={onContextMenu}
onCreateFile={onCreateFile}
level={level + 1}
/>
))
@@ -187,7 +217,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
)
}
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, level = 0 }: FileTreeProps) {
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, onContextMenu, onCreateFile, level = 0 }: FileTreeProps) {
return (
<div className="py-1">
{entries.map((entry) => (
@@ -197,6 +227,8 @@ export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, lev
selectedPath={selectedPath}
onSelect={onSelect}
onLoadDirectory={onLoadDirectory}
onContextMenu={onContextMenu}
onCreateFile={onCreateFile}
level={level}
/>
))}
-222
View File
@@ -1,222 +0,0 @@
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 */}
<div className="flex-1 overflow-auto p-6">
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
),
p: ({ children }) => (
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
),
code: ({ children, className }) => {
const isInline = !className
return isInline ? (
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
) : (
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 overflow-x-auto mb-4">
<code className="text-sm font-mono text-gray-300">{children}</code>
</pre>
)
},
ul: ({ children }) => (
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
),
li: ({ children }) => <li className="text-gray-300">{children}</li>,
a: ({ children, href }) => (
<a
href={href}
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
{children}
</blockquote>
),
hr: () => <hr className="border-shell-700 my-6" />,
table: ({ children }) => (
<table className="w-full border-collapse mb-4">{children}</table>
),
thead: ({ children }) => (
<thead className="bg-shell-800">{children}</thead>
),
th: ({ children }) => (
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">{content}</pre>
)}
</div>
</div>
)
}
export default MarkdownViewer
@@ -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-[10px] text-shell-600 truncate">
<p className="font-console text-[11px] text-shell-600 truncate">
{workspacePath}
</p>
</div>
+200
View File
@@ -0,0 +1,200 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { FileText, X, Plus, AlertCircle } from 'lucide-react'
interface NewFileDialogProps {
open: boolean
onClose: () => void
onCreate: (fileName: string, content: string) => void
folderPath?: string // If set, file is created in this folder
}
export function NewFileDialog({ open, onClose, onCreate, folderPath }: NewFileDialogProps) {
const [fileName, setFileName] = useState('')
const [content, setContent] = useState('')
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Focus input when dialog opens
useEffect(() => {
if (open) {
setFileName('')
setContent('')
setError(null)
const timeoutId = setTimeout(() => inputRef.current?.focus(), 50)
return () => clearTimeout(timeoutId)
}
}, [open])
const handleSubmit = useCallback(async () => {
setError(null)
if (!fileName.trim()) {
setError('Please enter a file name')
return
}
// Validate file name - allow forward slash for subdirectory creation
const invalidChars = /[<>:"\\|?*\x00-\x1f]/g
if (invalidChars.test(fileName)) {
setError('File name contains invalid characters')
return
}
// Check for path traversal - only block parent directory traversal, not relative paths
if (fileName.includes('..') || fileName.startsWith('/')) {
setError('File name cannot contain path traversal sequences')
return
}
// Create the file - wait for completion before closing
try {
await onCreate(fileName.trim(), content)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create file')
}
}, [fileName, content, onCreate, onClose])
const handleKeyDown = (e: React.KeyboardEvent) => {
// Only submit on Enter if focus is on the filename input (not content textarea)
if (e.key === 'Enter' && e.target === inputRef.current) {
handleSubmit()
}
if (e.key === 'Escape') {
onClose()
}
}
const addExtension = (ext: string) => {
if (!fileName.includes('.')) {
setFileName(fileName + ext)
}
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.3 }}
className="relative w-full max-w-lg bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-shell-800 bg-neon-mint/5">
<div className="p-2 rounded-lg bg-neon-mint/10 border border-neon-mint/30">
<Plus size={20} className="text-neon-mint" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-display text-lg text-gray-200">Create New File</h3>
{folderPath && (
<p className="font-console text-xs text-shell-500 truncate">
in {folderPath.split('/').pop()}
</p>
)}
</div>
</div>
{/* Content */}
<div className="px-5 py-4 space-y-4">
{/* File name input */}
<div>
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
File Name
</label>
<div className="relative">
<FileText size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
<input
ref={inputRef}
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="example.md"
className={`w-full bg-shell-800 border rounded-lg pl-10 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:ring-1 ${
error
? 'border-crab-500 focus:border-crab-500 focus:ring-crab-500/20'
: 'border-shell-700 focus:border-neon-mint focus:ring-neon-mint/20'
}`}
/>
</div>
{error && (
<div className="flex items-center gap-1.5 mt-2 text-crab-400">
<AlertCircle size={12} />
<span className="font-console text-xs">{error}</span>
</div>
)}
</div>
{/* Quick extension buttons */}
<div>
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
Quick Extensions
</label>
<div className="flex gap-2">
{['.md', '.txt', '.json', '.html'].map((ext) => (
<button
key={ext}
onClick={() => addExtension(ext)}
className="px-3 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-xs font-console text-gray-400 transition-colors border border-shell-700"
>
{ext}
</button>
))}
</div>
</div>
{/* Optional content */}
<div>
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
Initial Content (optional)
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter file content..."
className="w-full h-32 bg-shell-800 border border-shell-700 rounded-lg p-3 text-sm font-mono text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-neon-mint focus:ring-1 focus:ring-neon-mint/20"
spellCheck={false}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
<button
onClick={onClose}
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
>
<X size={14} />
Cancel
</button>
<button
onClick={handleSubmit}
className="flex items-center gap-2 px-4 py-2 bg-neon-mint hover:bg-neon-mint/90 rounded-lg text-sm font-console text-shell-950 transition-colors"
>
<Plus size={14} />
Create File
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
export default NewFileDialog
+4 -1
View File
@@ -1,5 +1,8 @@
export { FileTree } from './FileTree'
export { MarkdownViewer } from './MarkdownViewer'
export { FileEditor } from './FileEditor'
export { MobileBottomToolbar } from './MobileBottomToolbar'
export { MobileFileDrawer } from './MobileFileDrawer'
export { MobilePathSheet } from './MobilePathSheet'
export { ConfirmationDialog } from './ConfirmationDialog'
export { FileContextMenu, FileIcon, TrashIcon, CopyIcon, EditIcon, NewFileIcon } from './FileContextMenu'
export { NewFileDialog } from './NewFileDialog'
+462 -28
View File
@@ -9,15 +9,124 @@ import {
type AgentEvent,
type SessionInfo,
type SessionsListParams,
type ConnectChallengePayload,
createConnectParams,
} from './protocol'
import {
buildSignedDevice,
clearStoredDeviceToken,
getOrCreateIdentity,
loadStoredDeviceToken,
saveStoredDeviceToken,
} from './device'
interface ChallengePayload {
nonce: string
ts: number
}
const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
const DEFAULT_SCOPES = ['operator.read'] as const
type EventCallback = (event: EventFrame) => void
export type GatewayAuthState =
| 'unknown'
| 'authorized'
| 'unpaired'
| 'unauthorized'
| 'degraded'
interface PairingInfo {
requestId?: string
message?: string
}
function normalized(value: unknown): string | undefined {
return typeof value === 'string' ? value.trim() || undefined : undefined
}
function isTrustedLoopback(url: string): boolean {
try {
const u = new URL(url)
return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1'
} catch {
return false
}
}
/** Inline of openclaw selectGatewayConnectAuth / buildGatewayConnectAuth (operator subset). */
function selectConnectAuth(params: {
envToken?: string
storedToken?: string
storedScopes?: string[]
pendingDeviceTokenRetry?: boolean
trustedDeviceTokenRetry?: boolean
}) {
const authToken = normalized(params.envToken)
const storedToken = normalized(params.storedToken)
const useRetryToken =
params.pendingDeviceTokenRetry === true &&
Boolean(authToken && storedToken && params.trustedDeviceTokenRetry)
// Reference: resolved when retry OR (!(authToken) && stored)
const resolvedDeviceToken =
useRetryToken || (!authToken && storedToken) ? storedToken : undefined
const usingStoredDeviceToken =
Boolean(resolvedDeviceToken && storedToken) && resolvedDeviceToken === storedToken
const selectedToken = authToken ?? resolvedDeviceToken
return {
authToken: selectedToken,
// buildGatewayConnectAuth: deviceToken = authDeviceToken ?? resolvedDeviceToken
// select sets authDeviceToken only on retry; resolved covers stored-as-primary
authDeviceToken: (useRetryToken ? storedToken : undefined) ?? resolvedDeviceToken,
signatureToken: selectedToken ?? null,
usingStoredDeviceToken,
/** Stored token is auth.token (no env) — for clear-and-retry path. */
usingStoredAsPrimary: Boolean(!authToken && selectedToken && selectedToken === storedToken),
storedScopes: params.storedScopes,
}
}
function shouldRetryWithDeviceToken(params: {
retryBudgetUsed: boolean
currentDeviceToken?: string
explicitToken?: string
storedToken?: string
trustedEndpoint: boolean
error?: { code?: string; message?: string; details?: unknown }
}): boolean {
if (
params.retryBudgetUsed ||
params.currentDeviceToken ||
!params.explicitToken ||
!params.storedToken ||
!params.trustedEndpoint
) {
return false
}
const code = params.error?.code ?? ''
const message = (params.error?.message ?? '').toLowerCase()
const details = JSON.stringify(params.error?.details ?? '')
return (
code === 'AUTH_TOKEN_MISMATCH' ||
message.includes('auth_token_mismatch') ||
message.includes('retry_with_device_token') ||
details.includes('retry_with_device_token') ||
details.includes('AUTH_TOKEN_MISMATCH')
)
}
function shouldRetryClearStoredToken(params: {
retryBudgetUsed: boolean
usingStoredAsPrimary: boolean
envToken?: string
error?: { code?: string; message?: string; details?: unknown }
}): boolean {
if (params.retryBudgetUsed || !params.usingStoredAsPrimary || !params.envToken) return false
const code = params.error?.code ?? ''
const message = (params.error?.message ?? '').toLowerCase()
const details = JSON.stringify(params.error?.details ?? '')
return (
code === 'AUTH_TOKEN_MISMATCH' ||
message.includes('auth_token_mismatch') ||
message.includes('retry_with_device_token') ||
details.includes('AUTH_TOKEN_MISMATCH')
)
}
export class ClawdbotClient {
private ws: WebSocket | null = null
@@ -30,9 +139,24 @@ 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 = 'ws://127.0.0.1:18789',
private url: string = DEFAULT_GATEWAY_URL,
private token?: string
) {}
@@ -40,17 +164,39 @@ 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: 3 } as HelloOk
return { type: 'hello-ok', protocol: 4 } as HelloOk
}
this._connecting = true
this._connectPromiseSettled = false
this._authRetryUsed = false
this._pendingDeviceTokenRetry = false
this._lastConnectAuth = null
return new Promise((resolve, reject) => {
this._connectResolve = resolve
this._connectReject = reject
const timeout = setTimeout(() => {
this._connecting = false
this.ws?.close()
reject(new Error('Connection timeout - is openclaw gateway running?'))
if (!this._connectPromiseSettled) {
this._connectPromiseSettled = true
reject(new Error('Connection timeout - is openclaw gateway running?'))
}
}, 10000)
this._connectTimeout = timeout
try {
this.ws = new WebSocket(this.url)
@@ -61,7 +207,7 @@ export class ClawdbotClient {
}
this.ws.once('open', () => {
// WebSocket connected, waiting for challenge
this.debugLog('socket open, waiting for connect.challenge')
})
this.ws.on('message', (data) => {
@@ -71,11 +217,11 @@ export class ClawdbotClient {
// Handle challenge-response auth
if (msg.type === 'event' && msg.event === 'connect.challenge') {
this.handleChallenge(msg.payload as ChallengePayload)
this.handleChallenge(msg.payload as ConnectChallengePayload)
return
}
this.handleMessage(msg, resolve, reject, timeout)
this.handleMessage(msg)
} catch (e) {
console.error('[openclaw] Failed to parse message:', e)
}
@@ -84,14 +230,33 @@ export class ClawdbotClient {
this.ws.on('error', (err) => {
clearTimeout(timeout)
this._connecting = false
reject(err)
this.debugLog('socket error before connect', err)
if (!this._connectPromiseSettled) {
this._connectPromiseSettled = true
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()
@@ -100,12 +265,69 @@ export class ClawdbotClient {
})
}
private handleChallenge(_challenge: ChallengePayload) {
if (!this.token || this.ws?.readyState !== WebSocket.OPEN) {
private handleChallenge(challenge: ConnectChallengePayload) {
if (this.ws?.readyState !== WebSocket.OPEN) {
return
}
const params = createConnectParams(this.token)
const stored = loadStoredDeviceToken()
const selected = selectConnectAuth({
envToken: this.token,
storedToken: stored?.token,
storedScopes: stored?.scopes,
pendingDeviceTokenRetry: this._pendingDeviceTokenRetry,
trustedDeviceTokenRetry: isTrustedLoopback(this.url),
})
const scopes =
selected.usingStoredDeviceToken && stored?.scopes?.length
? stored.scopes
: [...DEFAULT_SCOPES]
let params = createConnectParams({
token: selected.authToken,
deviceToken: selected.authDeviceToken,
scopes,
})
// Payload platform must match client.platform after normalize (createConnectParams sets raw platform)
try {
const device = buildSignedDevice({
challenge,
token: selected.signatureToken,
role: params.role,
scopes: params.scopes,
clientId: params.client.id,
clientMode: params.client.mode,
platform: params.client.platform,
})
params = createConnectParams({
token: selected.authToken,
deviceToken: selected.authDeviceToken,
scopes,
device,
})
} catch (error) {
console.error('[openclaw] Failed to create signed device identity:', error)
}
this._lastConnectAuth = {
authDeviceToken: selected.authDeviceToken,
usingStoredAsPrimary: selected.usingStoredAsPrimary,
}
this.debugLog('sending connect', {
hasToken: Boolean(params.auth?.token),
hasDeviceToken: Boolean(params.auth?.deviceToken),
hasDevice: Boolean(params.device),
deviceId: params.device?.id,
usingStored: selected.usingStoredDeviceToken,
pendingRetry: this._pendingDeviceTokenRetry,
clientMode: params.client.mode,
clientPlatform: params.client.platform,
scopes: params.scopes,
})
const response: RequestFrame = {
type: 'req',
id: `connect-${Date.now()}`,
@@ -116,27 +338,149 @@ export class ClawdbotClient {
this.ws.send(JSON.stringify(response))
}
private handleMessage(
msg: GatewayFrame | HelloOk,
connectResolve?: (v: HelloOk) => void,
_connectReject?: (e: Error) => void,
connectTimeout?: ReturnType<typeof setTimeout>
) {
private handleConnectFailure(error?: { code?: string; message?: string; details?: unknown }) {
const stored = loadStoredDeviceToken()
const trusted = isTrustedLoopback(this.url)
const last = this._lastConnectAuth
// Env primary failed → retry once with cached device token
if (
shouldRetryWithDeviceToken({
retryBudgetUsed: this._authRetryUsed,
currentDeviceToken: last?.authDeviceToken,
explicitToken: this.token,
storedToken: stored?.token,
trustedEndpoint: trusted,
error,
})
) {
this._authRetryUsed = true
this._pendingDeviceTokenRetry = true
this.debugLog('AUTH_TOKEN_MISMATCH — retrying with device token')
this.reopenForAuthRetry()
return
}
// Stored primary failed → clear token file, retry once with env token only
if (
shouldRetryClearStoredToken({
retryBudgetUsed: this._authRetryUsed,
usingStoredAsPrimary: Boolean(last?.usingStoredAsPrimary),
envToken: this.token,
error,
})
) {
this._authRetryUsed = true
this._pendingDeviceTokenRetry = false
clearStoredDeviceToken()
this.debugLog('AUTH_TOKEN_MISMATCH — cleared stored device token, retrying with env')
this.reopenForAuthRetry()
return
}
const message = error?.message || 'Connect failed'
this.updateAuthStateFromError(message)
if (this._connectTimeout) clearTimeout(this._connectTimeout)
this._connecting = false
if (!this._connectPromiseSettled) {
this._connectPromiseSettled = true
this._connectReject?.(new Error(message))
}
}
/** Re-open socket for one-shot auth retry without settling the outer connect promise. */
private reopenForAuthRetry() {
// Detach old socket so its close handler cannot reject the connect promise.
const old = this.ws
this.ws = null
if (old) {
old.removeAllListeners()
old.on('error', () => {})
try {
old.close()
} catch {
// ignore
}
}
this._connected = false
try {
this.ws = new WebSocket(this.url)
} catch (e) {
this._connecting = false
if (!this._connectPromiseSettled) {
this._connectPromiseSettled = true
this._connectReject?.(new Error(`Failed to create WebSocket for auth retry: ${e}`))
}
return
}
this.ws.once('open', () => {
this.debugLog('auth-retry socket open, waiting for connect.challenge')
})
this.ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString())
if (msg.type === 'event' && msg.event === 'connect.challenge') {
this.handleChallenge(msg.payload as ConnectChallengePayload)
return
}
this.handleMessage(msg)
} catch (e) {
console.error('[openclaw] Failed to parse message:', e)
}
})
this.ws.on('error', (err) => {
this.debugLog('auth-retry socket error', err)
if (!this._connectPromiseSettled) {
this._connectPromiseSettled = true
this._connecting = false
this._connectReject?.(err instanceof Error ? err : new Error(String(err)))
}
})
this.ws.on('close', (code, reason) => {
this.debugLog('auth-retry socket close', {
code,
reason: reason?.toString?.() ?? '',
})
if (this._connecting && !this._connectPromiseSettled && !this._connected) {
this._connectPromiseSettled = true
this._connecting = false
this._connectReject?.(
new Error(
`Gateway closed during auth retry (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})`
)
)
}
})
}
private handleMessage(msg: GatewayFrame | HelloOk) {
if ('type' in msg) {
switch (msg.type) {
case 'hello-ok':
if (connectTimeout) clearTimeout(connectTimeout)
if (this._connectTimeout) clearTimeout(this._connectTimeout)
this.updateAuthStateFromHello(msg)
this._connected = true
connectResolve?.(msg)
this._connecting = false
this._connectPromiseSettled = true
this._connectResolve?.(msg)
break
case 'res':
// Check if this is the hello-ok response to our connect request
if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') {
if (connectTimeout) clearTimeout(connectTimeout)
if (this._connectTimeout) clearTimeout(this._connectTimeout)
this.updateAuthStateFromHello(msg.payload as HelloOk)
this._connected = true
this._connecting = false
connectResolve?.(msg.payload as HelloOk)
this._connectPromiseSettled = true
this._connectResolve?.(msg.payload as HelloOk)
} else if (!msg.ok && String(msg.id).startsWith('connect-')) {
this.handleConnectFailure(msg.error)
} else {
this.handleResponse(msg)
}
@@ -160,12 +504,25 @@ export class ClawdbotClient {
if (res.ok) {
pending.resolve(res.payload)
} else {
pending.reject(new Error(res.error?.message || 'Request failed'))
const message = res.error?.message || 'Request failed'
this.updateAuthStateFromError(message)
pending.reject(new Error(message))
}
}
}
private handleEvent(event: EventFrame) {
if (event.event.includes('pair') || event.event.includes('device')) {
const payload = event.payload as { requestId?: string; message?: string } | undefined
if (payload?.requestId || payload?.message) {
this._authState = 'unpaired'
this._pairingInfo = {
requestId: payload.requestId,
message: payload.message,
}
}
}
for (const listener of this.eventListeners) {
try {
listener(event)
@@ -233,17 +590,94 @@ 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(url, token)
clientInstance = new ClawdbotClient(DEFAULT_GATEWAY_URL, token)
}
return clientInstance
}
@@ -0,0 +1,35 @@
/**
* Self-check for v3 device-auth payload (no test framework).
* Run: node --experimental-strip-types src/integrations/openclaw/device-auth.selfcheck.ts
*/
import {
buildDeviceAuthPayloadV3,
normalizeDeviceMetadataForAuth,
} from './device.ts'
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg)
}
const platform = normalizeDeviceMetadataForAuth('Linux')
assert(platform === 'linux', `normalize DeviceMetadata: expected 'linux', got '${platform}'`)
const payload = buildDeviceAuthPayloadV3({
deviceId: 'abc123',
clientId: 'cli',
clientMode: 'cli',
role: 'operator',
scopes: ['operator.read'],
signedAtMs: 1700000000000,
token: 'tok',
nonce: 'nonce-1',
platform: 'Linux',
deviceFamily: '',
})
const expected =
'v3|abc123|cli|cli|operator|operator.read|1700000000000|tok|nonce-1|linux|'
assert(payload === expected, `payload mismatch:\n got: ${payload}\n exp: ${expected}`)
console.log('device-auth.selfcheck: ok')
+242
View File
@@ -0,0 +1,242 @@
import fs from 'fs'
import path from 'path'
import {
createHash,
createPrivateKey,
generateKeyPairSync,
sign,
} from 'crypto'
import type { ConnectChallengePayload, ConnectDevice } from './protocol'
const DATA_DIR = path.join(process.cwd(), 'data')
const DEVICE_IDENTITY_FILE = path.join(DATA_DIR, 'device-identity.json')
const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.json')
interface StoredDeviceIdentity {
id: string
publicKey: string
privateKeyPem: string
createdAt: number
lastUsedAt: number
}
export interface StoredDeviceToken {
deviceId: string
token: string
role?: string
scopes?: string[]
updatedAtMs: number
}
function base64UrlToBuffer(value: string): Buffer {
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=')
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/')
return Buffer.from(base64, 'base64')
}
function base64UrlEncode(value: Buffer): string {
return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
}
function decodePublicKey(value: string): Buffer {
try {
const raw = base64UrlToBuffer(value)
if (raw.length > 0) return raw
} catch {
// fallback below
}
return Buffer.from(value, 'base64')
}
function fingerprintFromPublicKey(publicKey: string): string {
const rawPublicKey = decodePublicKey(publicKey)
return createHash('sha256').update(rawPublicKey).digest('hex')
}
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true })
}
}
function loadStoredIdentity(): StoredDeviceIdentity | null {
try {
if (!fs.existsSync(DEVICE_IDENTITY_FILE)) {
return null
}
const data = JSON.parse(fs.readFileSync(DEVICE_IDENTITY_FILE, 'utf-8')) as StoredDeviceIdentity
if (!data.publicKey || !data.privateKeyPem) {
return null
}
const canonicalId = fingerprintFromPublicKey(data.publicKey)
const normalized: StoredDeviceIdentity = {
...data,
id: canonicalId,
}
// Auto-migrate legacy id formats to canonical fingerprint.
if (data.id !== canonicalId) {
saveStoredIdentity(normalized)
}
return normalized
} catch {
return null
}
}
function saveStoredIdentity(identity: StoredDeviceIdentity) {
ensureDataDir()
fs.writeFileSync(DEVICE_IDENTITY_FILE, JSON.stringify(identity, null, 2), { mode: 0o600 })
try {
fs.chmodSync(DEVICE_IDENTITY_FILE, 0o600)
} catch {
// ponytail: chmod best-effort on platforms that ignore mode
}
}
function generateStoredIdentity(): StoredDeviceIdentity {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const publicJwk = publicKey.export({ format: 'jwk' })
if (!publicJwk.x) {
throw new Error('Failed to export Ed25519 public key')
}
const rawPublicKey = base64UrlToBuffer(publicJwk.x)
const fingerprint = createHash('sha256').update(rawPublicKey).digest('hex')
const now = Date.now()
return {
id: fingerprint,
publicKey: base64UrlEncode(rawPublicKey),
privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
createdAt: now,
lastUsedAt: now,
}
}
/** Verbatim from openclaw gateway-client device-auth.ts */
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (typeof value !== 'string') {
return ''
}
const trimmed = value.trim()
if (!trimmed) {
return ''
}
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32))
}
export function buildDeviceAuthPayloadV3(params: {
deviceId: string
clientId: string
clientMode: string
role: string
scopes: string[]
signedAtMs: number
token?: string | null
nonce: string
platform?: string | null
deviceFamily?: string | null
}): string {
const scopes = params.scopes.join(',')
const token = params.token ?? ''
const platform = normalizeDeviceMetadataForAuth(params.platform)
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily)
return [
'v3',
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
params.nonce,
platform,
deviceFamily,
].join('|')
}
interface BuildSignedDeviceParams {
challenge: ConnectChallengePayload
token: string | null
role: string
scopes: string[]
clientId: string
clientMode: string
platform?: string | null
deviceFamily?: string | null
}
export function getOrCreateIdentity(): StoredDeviceIdentity {
const existing = loadStoredIdentity()
if (existing) {
return existing
}
const generated = generateStoredIdentity()
saveStoredIdentity(generated)
return generated
}
export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevice {
const nonce = params.challenge.nonce?.trim()
if (!nonce) {
throw new Error('connect.challenge nonce is empty — refusing to sign (would fall back to v1)')
}
const identity = getOrCreateIdentity()
const privateKey = createPrivateKey(identity.privateKeyPem)
const signedAt = Date.now()
const payload = buildDeviceAuthPayloadV3({
deviceId: identity.id,
clientId: params.clientId,
clientMode: params.clientMode,
role: params.role,
scopes: params.scopes,
signedAtMs: signedAt,
token: params.token,
nonce,
platform: params.platform,
deviceFamily: params.deviceFamily ?? '',
})
const signature = base64UrlEncode(sign(null, Buffer.from(payload, 'utf8'), privateKey))
identity.lastUsedAt = signedAt
saveStoredIdentity(identity)
return {
id: identity.id,
publicKey: identity.publicKey,
signature,
signedAt,
nonce,
}
}
export function loadStoredDeviceToken(): StoredDeviceToken | null {
try {
if (!fs.existsSync(DEVICE_TOKEN_FILE)) return null
const data = JSON.parse(fs.readFileSync(DEVICE_TOKEN_FILE, 'utf-8')) as StoredDeviceToken
if (!data.token || !data.deviceId) return null
return data
} catch {
return null
}
}
export function saveStoredDeviceToken(entry: StoredDeviceToken) {
ensureDataDir()
fs.writeFileSync(DEVICE_TOKEN_FILE, JSON.stringify(entry, null, 2), { mode: 0o600 })
try {
fs.chmodSync(DEVICE_TOKEN_FILE, 0o600)
} catch {
// ponytail: chmod best-effort
}
}
export function clearStoredDeviceToken() {
try {
if (fs.existsSync(DEVICE_TOKEN_FILE)) fs.unlinkSync(DEVICE_TOKEN_FILE)
} catch {
// ignore
}
}
+7 -1
View File
@@ -55,7 +55,13 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
}
}
if (event.message) {
// v4: deltaText when replace===true or message absent; else cumulative message path
if (
typeof event.deltaText === 'string' &&
(event.replace === true || event.message == null)
) {
action.content = event.deltaText
} else if (event.message) {
if (typeof event.message === 'string') {
action.content = event.message
} else if (typeof event.message === 'object') {
+79 -15
View File
@@ -1,4 +1,4 @@
// Clawdbot Gateway Protocol v3 types
// Clawdbot Gateway Protocol v4 types
// Frame types
export interface RequestFrame {
@@ -13,7 +13,7 @@ export interface ResponseFrame {
id: string
ok: boolean
payload?: unknown
error?: { code: string; message: string }
error?: { code: string; message: string; details?: unknown }
}
export interface EventFrame {
@@ -32,14 +32,34 @@ export interface ClientInfo {
displayName: string
version: string
platform: string
mode: 'ui' | 'cli' | 'bot'
mode: 'ui' | 'cli' | 'bot' | 'operator' | 'node'
}
export interface ConnectParams {
minProtocol: 3
maxProtocol: 3
minProtocol: 3 | 4
maxProtocol: 3 | 4
client: ClientInfo
auth?: { token?: string }
auth?: { token?: string; deviceToken?: string }
device?: ConnectDevice
}
export interface ConnectChallengePayload {
nonce: string
ts: number
}
export interface ConnectDevice {
id: string
publicKey: string
signature: string
signedAt: number
nonce: string
}
export interface HelloAuth {
role?: string
scopes?: string[]
deviceToken?: string
}
export interface HelloOk {
@@ -51,6 +71,7 @@ export interface HelloOk {
stateVersion: { presence: number; health: number }
}
features: { methods: string[]; events: string[] }
auth?: HelloAuth
}
export interface PresenceEntry {
@@ -59,14 +80,15 @@ export interface PresenceEntry {
connectedAt: number
}
// Chat events
// Note: gateway sends cumulative message content with each delta, not incremental chars
// Chat events — v4 may send deltaText (incremental) and/or cumulative message
export interface ChatEvent {
runId: string
sessionKey: string
seq: number
state: 'delta' | 'final' | 'aborted' | 'error'
message?: unknown
deltaText?: string
replace?: boolean
errorMessage?: string
usage?: {
inputTokens?: number
@@ -231,24 +253,66 @@ export function parseSessionKey(key: string): {
return { agentId, platform, recipient, isGroup }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createConnectParams(token?: string): any {
export type CreateConnectOptions = {
token?: string
deviceToken?: string
device?: ConnectDevice
scopes?: string[]
}
export function createConnectParams(
tokenOrOpts?: string | CreateConnectOptions,
device?: ConnectDevice
): ConnectParams & {
role: string
scopes: string[]
caps: unknown[]
commands: unknown[]
permissions: Record<string, unknown>
locale: string
userAgent: string
} {
// ponytail: overload keeps call sites short; prefer opts object for deviceToken/scopes
const opts: CreateConnectOptions =
typeof tokenOrOpts === 'string' || tokenOrOpts === undefined
? { token: tokenOrOpts, device }
: tokenOrOpts
const platformMap: Record<string, string> = {
win32: 'windows',
darwin: 'macos',
linux: 'linux',
}
const platform = platformMap[process.platform] ?? process.platform
const authToken = opts.token
const authDeviceToken = opts.deviceToken
const auth =
authToken || authDeviceToken
? {
...(authToken ? { token: authToken } : {}),
...(authDeviceToken ? { deviceToken: authDeviceToken } : {}),
}
: undefined
return {
minProtocol: 3,
maxProtocol: 3,
minProtocol: 4,
maxProtocol: 4,
client: {
id: 'cli',
displayName: 'crabwalk-monitor',
version: '0.1.0',
platform: 'linux',
platform,
mode: 'cli',
},
role: 'operator',
scopes: ['operator.read'],
scopes: opts.scopes ?? ['operator.read'],
caps: [],
commands: [],
permissions: {},
locale: 'en-US',
userAgent: 'crabwalk-monitor/0.1.0',
auth: token ? { token } : undefined,
auth,
device: opts.device,
}
}
+88 -4
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 } from '~/integrations/openclaw/client'
import { getClawdbotClient, getClawdbotEndpoint } from '~/integrations/openclaw/client'
import { getPersistenceService } from '~/integrations/openclaw/persistence'
import {
parseEventFrame,
@@ -14,6 +14,9 @@ import {
import {
listDirectory,
readFile,
writeFile,
deleteFile,
createFile,
pathExists,
getDefaultWorkspacePath,
expandTilde,
@@ -40,7 +43,12 @@ const openclawRouter = router({
connect: publicProcedure.mutation(async () => {
const client = getClawdbotClient()
if (client.connected) {
return { status: 'already_connected' as const }
return {
status: 'already_connected' as const,
authState: client.authState,
scopes: client.scopes,
pairing: client.pairingInfo,
}
}
try {
const hello = await client.connect()
@@ -49,11 +57,17 @@ 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,
}
}
}),
@@ -69,6 +83,20 @@ 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 }) => {
@@ -127,7 +155,7 @@ const openclawRouter = router({
const client = getClawdbotClient()
const persistence = getPersistenceService()
if (!client.connected) {
return { sessions: [], error: 'Not connected' }
return { sessions: [], error: 'Not connected', authState: client.authState }
}
try {
const sessions = await client.listSessions(input)
@@ -136,11 +164,14 @@ const openclawRouter = router({
for (const session of monitorSessions) {
persistence.upsertSession(session)
}
return { sessions: monitorSessions }
return { sessions: monitorSessions, authState: client.authState, scopes: client.scopes }
} catch (error) {
return {
sessions: [],
error: error instanceof Error ? error.message : 'Failed to list sessions',
authState: client.authState,
scopes: client.scopes,
pairing: client.pairingInfo,
}
}
}),
@@ -286,6 +317,59 @@ const workspaceRouter = router({
}
}
}),
// Write file contents
writeFile: publicProcedure
.input(z.object({ workspaceRoot: z.string(), path: z.string(), content: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
try {
const expandedRoot = expandTilde(input.workspaceRoot)
const expandedPath = expandTilde(input.path)
await writeFile(expandedRoot, expandedPath, input.content)
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write file',
}
}
}),
// Delete file
deleteFile: publicProcedure
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
try {
const expandedRoot = expandTilde(input.workspaceRoot)
const expandedPath = expandTilde(input.path)
await deleteFile(expandedRoot, expandedPath)
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete file',
}
}
}),
// Create file
createFile: publicProcedure
.input(z.object({ workspaceRoot: z.string(), fileName: z.string(), content: z.string().optional() }))
.mutation(async ({ input }): Promise<{ success: boolean; error?: string; filePath?: string }> => {
try {
const expandedRoot = expandTilde(input.workspaceRoot)
// Construct path server-side using Node.js path.join
const path = await import('path')
const fullPath = path.join(expandedRoot, input.fileName)
await createFile(expandedRoot, fullPath, input.content || '')
return { success: true, filePath: fullPath }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create file',
}
}
}),
})
export const appRouter = router({
+115 -8
View File
@@ -24,26 +24,31 @@ export interface FileContent {
/**
* Validates that a path is within the allowed workspace root
* Prevents directory traversal attacks
* Prevents directory traversal attacks and symlink escapes
*/
export function validatePath(workspaceRoot: string, targetPath: string): string {
export async function validatePath(workspaceRoot: string, targetPath: string): Promise<string> {
// Resolve to absolute paths
const resolvedRoot = path.resolve(workspaceRoot)
const resolvedTarget = path.resolve(targetPath)
// Resolve symlinks to prevent escaping workspace via symlinked paths
// This ensures we validate the actual filesystem location, not the symlink itself
const realRoot = await fs.realpath(resolvedRoot)
const realTarget = await fs.realpath(resolvedTarget)
// Normalize paths for cross-platform comparison
// Convert backslashes to forward slashes and ensure consistent formatting
const normalizeForComparison = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '')
const normalizedRoot = normalizeForComparison(resolvedRoot) + '/'
const normalizedTarget = normalizeForComparison(resolvedTarget)
const normalizedRoot = normalizeForComparison(realRoot) + '/'
const normalizedTarget = normalizeForComparison(realTarget)
// Ensure target path is within root path by checking with trailing separator
// This prevents bypasses like /home/user/workspace-evil matching /home/user/workspace
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(resolvedRoot)) {
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(realRoot)) {
throw new Error('Path traversal detected: target path is outside workspace root')
}
return resolvedTarget
return realTarget
}
/**
@@ -54,7 +59,7 @@ export async function listDirectory(
workspaceRoot: string,
targetPath: string
): Promise<DirectoryEntry[]> {
const safePath = validatePath(workspaceRoot, targetPath)
const safePath = await validatePath(workspaceRoot, targetPath)
try {
const entries = await fs.readdir(safePath, { withFileTypes: true })
@@ -111,7 +116,7 @@ export async function readFile(
workspaceRoot: string,
filePath: string
): Promise<FileContent> {
const safePath = validatePath(workspaceRoot, filePath)
const safePath = await validatePath(workspaceRoot, filePath)
try {
// Check if file exists and is a file
@@ -250,3 +255,105 @@ export function isTextFile(filename: string): boolean {
const ext = lastDotIndex > 0 ? path.extname(filename).toLowerCase() : ''
return textExtensions.includes(ext) || ext === ''
}
/**
* Writes content to a file
* Creates the file if it doesn't exist, overwrites if it does
*/
export async function writeFile(
workspaceRoot: string,
filePath: string,
content: string
): Promise<void> {
const safePath = await validatePath(workspaceRoot, filePath)
try {
// Check if parent directory exists
const parentDir = path.dirname(safePath)
const parentStats = await fs.stat(parentDir)
if (!parentStats.isDirectory()) {
throw new Error('Parent path is not a directory')
}
// Write file content
await fs.writeFile(safePath, content, 'utf-8')
} catch (error) {
throw new Error(
`Failed to write file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Deletes a file
*/
export async function deleteFile(
workspaceRoot: string,
filePath: string
): Promise<void> {
const safePath = await validatePath(workspaceRoot, filePath)
try {
// Check if file exists and is a file
const stats = await fs.stat(safePath)
if (!stats.isFile()) {
throw new Error('Path is not a file')
}
// Delete the file
await fs.unlink(safePath)
} catch (error) {
throw new Error(
`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Creates a new file with optional content
*/
export async function createFile(
workspaceRoot: string,
filePath: string,
content: string = ''
): Promise<void> {
// Expand user home directory and resolve path
const expandedRoot = expandTilde(workspaceRoot)
const expandedFilePath = expandTilde(filePath)
// Get the parent directory path
const parentDir = path.dirname(expandedFilePath)
const fileName = path.basename(expandedFilePath)
// Validate that parent directory is within workspace root
const safeParentPath = await validatePath(expandedRoot, parentDir)
// Check if file already exists
const exists = await pathExists(expandedFilePath)
if (exists) {
throw new Error('File already exists')
}
// Check if parent directory exists
try {
const parentStats = await fs.stat(safeParentPath)
if (!parentStats.isDirectory()) {
throw new Error('Parent path is not a directory')
}
} catch (error) {
// Preserve original error if it's already a descriptive error we threw
if (error instanceof Error && error.message === 'Parent path is not a directory') {
throw error
}
throw new Error('Parent directory does not exist')
}
// Create the file
try {
await fs.writeFile(expandedFilePath, content, 'utf-8')
} catch (error) {
throw new Error(
`Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
+96 -14
View File
@@ -64,15 +64,26 @@ 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
@@ -115,9 +126,20 @@ 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()
@@ -139,18 +161,43 @@ 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)
// Hydrate from persistence if enabled
await hydrateFromPersistence()
await loadSessions()
if (result.authState === 'authorized' || result.authState === 'unknown') {
// Hydrate from persistence if enabled
await hydrateFromPersistence()
await loadSessions()
}
return
}
} catch {
@@ -187,17 +234,24 @@ 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 = async () => {
const loadSessions = useCallback(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)
@@ -206,15 +260,18 @@ function MonitorPage() {
} catch (e) {
console.error('Failed to load sessions:', e)
}
}
}, [historicalMode])
const handleRefresh = useCallback(async () => {
await loadSessions()
}, [historicalMode])
await checkAuthStatus()
if (canPollSessions) {
await loadSessions()
}
}, [checkAuthStatus, canPollSessions, loadSessions])
const handleHistoricalModeChange = (enabled: boolean) => {
setHistoricalMode(enabled)
if (connected) {
if (canPollSessions) {
loadSessions()
}
}
@@ -325,6 +382,15 @@ 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)
}, [])
@@ -338,16 +404,16 @@ function MonitorPage() {
// Poll for sessions while connected
useEffect(() => {
if (!connected) return
if (!canPollSessions) return
const interval = setInterval(() => {
loadSessions()
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [connected, historicalMode])
}, [canPollSessions, loadSessions])
// Subscribe to real-time events
useEffect(() => {
if (!connected) return
if (!canPollSessions) return
const subscription = trpc.openclaw.events.subscribe(undefined, {
onData: (data) => {
@@ -369,7 +435,11 @@ function MonitorPage() {
return () => {
subscription.unsubscribe()
}
}, [connected])
}, [canPollSessions])
const pairingHint = pairing?.requestId
? `openclaw devices approve ${pairing.requestId}`
: 'openclaw devices list && openclaw devices approve <requestId>'
return (
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
@@ -448,12 +518,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-[10px] text-shell-500 uppercase">Sessions</span>
<span className="font-console text-[11px] text-shell-500 uppercase">Sessions</span>
<span className="font-display text-sm text-neon-mint">{sessions.length}</span>
</div>
<div className="w-px h-4 bg-shell-700" />
<div className="flex items-center gap-2">
<span className="font-console text-[10px] text-shell-500 uppercase">Actions</span>
<span className="font-console text-[11px] text-shell-500 uppercase">Actions</span>
<span className="font-display text-sm text-neon-peach">{actions.length}</span>
</div>
</div>
@@ -468,6 +538,7 @@ function MonitorPage() {
persistenceStartedAt={persistenceStartedAt}
persistenceSessionCount={persistenceSessionCount}
persistenceActionCount={persistenceActionCount}
gatewayEndpoint={gatewayEndpoint}
open={settingsOpen}
onOpenChange={setSettingsOpen}
onHistoricalModeChange={handleHistoricalModeChange}
@@ -485,6 +556,17 @@ 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 */}
+242 -22
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { motion } from 'framer-motion'
import {
@@ -10,14 +10,21 @@ import {
PanelLeftClose,
Star,
FileText,
Plus,
} from 'lucide-react'
import { trpc } from '~/integrations/trpc/client'
import {
FileTree,
MarkdownViewer,
FileEditor,
MobileBottomToolbar,
MobileFileDrawer,
MobilePathSheet,
ConfirmationDialog,
FileContextMenu,
TrashIcon,
CopyIcon,
EditIcon,
NewFileDialog,
} from '~/components/workspace'
import { NavTabs } from '~/components/navigation'
import { CrabIdleAnimation } from '~/components/ani'
@@ -356,7 +363,174 @@ function WorkspacePage() {
})
}, [])
// Delete confirmation dialog state
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
// New file dialog state
const [newFileDialogOpen, setNewFileDialogOpen] = useState(false)
const [newFileFolderPath, setNewFileFolderPath] = useState<string | null>(null)
// Context menu state
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [contextMenuFilePath, setContextMenuFilePath] = useState<string | null>(null)
// Clipboard feedback state
const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle')
// Handle save file with confirmation callback
const handleSave = useCallback(
async (content: string, callback: (success: boolean) => void) => {
if (!selectedPath) {
callback(false)
return
}
try {
await trpc.workspace.writeFile.mutate({
workspaceRoot: workspacePath,
path: selectedPath,
content,
})
// Reload the file to get updated metadata
await loadFile(selectedPath)
// Refresh the directory to update metadata
await handleRefresh()
callback(true)
} catch (error) {
console.error('Failed to save file:', error)
callback(false)
}
},
[selectedPath, workspacePath, loadFile, handleRefresh]
)
// Handle delete file
const handleDeleteFile = useCallback(async () => {
if (!fileToDelete) return
try {
await trpc.workspace.deleteFile.mutate({
workspaceRoot: workspacePath,
path: fileToDelete,
})
// If deleted file was selected, clear selection
if (selectedPath === fileToDelete) {
setSelectedPath(null)
setSelectedFileContent('')
setSelectedFileName('')
setSelectedFileSize(undefined)
setSelectedFileModified(undefined)
}
// Clear cache and refresh
setPathCache(new Map())
await handleRefresh()
} catch (error) {
console.error('Failed to delete file:', error)
} finally {
setFileToDelete(null)
setDeleteConfirmOpen(false)
}
}, [fileToDelete, workspacePath, selectedPath, handleRefresh])
// Handle create file (uses newFileFolderPath if set)
const handleCreateFile = useCallback(
async (fileName: string, content: string) => {
try {
// If creating in a subfolder, prepend the relative path
let finalFileName = fileName
if (newFileFolderPath && newFileFolderPath !== workspacePath) {
const relativePath = newFileFolderPath.replace(workspacePath + '/', '')
finalFileName = `${relativePath}/${fileName}`
}
const result = await trpc.workspace.createFile.mutate({
workspaceRoot: workspacePath,
fileName: finalFileName,
content,
})
if (result.success && result.filePath) {
// Clear cache and refresh
setPathCache(new Map())
await handleRefresh()
// Select the new file
setSelectedPath(result.filePath)
await loadFile(result.filePath)
} else if (result.error) {
throw new Error(result.error)
}
} catch (error) {
console.error('Failed to create file:', error)
throw error
} finally {
setNewFileFolderPath(null)
}
},
[workspacePath, newFileFolderPath, loadFile, handleRefresh]
)
// Handle context menu
const handleContextMenu = useCallback(
(e: React.MouseEvent, filePath: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenuFilePath(filePath)
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuOpen(true)
},
[]
)
// Handle create file in folder (from + button in tree)
const handleCreateFileInFolder = useCallback((folderPath: string) => {
setNewFileFolderPath(folderPath)
setNewFileDialogOpen(true)
}, [])
// Context menu items
const contextMenuItems = useMemo(() => [
{
icon: <EditIcon size={14} />,
label: 'Edit',
onClick: () => {
if (contextMenuFilePath) {
handleSelect(contextMenuFilePath, 'file')
}
},
},
{
icon: <CopyIcon size={14} />,
label: copyFeedback === 'copied' ? 'Copied!' : copyFeedback === 'failed' ? 'Failed' : 'Copy Path',
onClick: async () => {
if (contextMenuFilePath) {
try {
await navigator.clipboard.writeText(contextMenuFilePath)
setCopyFeedback('copied')
setTimeout(() => setCopyFeedback('idle'), 2000)
} catch {
setCopyFeedback('failed')
setTimeout(() => setCopyFeedback('idle'), 2000)
}
}
},
},
{
icon: <TrashIcon size={14} />,
label: 'Delete',
danger: true,
onClick: () => {
if (contextMenuFilePath) {
setFileToDelete(contextMenuFilePath)
setDeleteConfirmOpen(true)
}
},
},
], [contextMenuFilePath, handleSelect, copyFeedback])
return (
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
@@ -436,14 +610,14 @@ function WorkspacePage() {
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
>
{/* Sidebar header */}
{/* Sidebar header */}
<div className={`flex items-center justify-between px-3 py-3 border-b border-shell-800 ${sidebarCollapsed ? 'justify-center' : ''}`}>
{!sidebarCollapsed && (
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
Files
</span>
)}
<div className={`flex items-center gap-2 ${sidebarCollapsed ? 'mx-auto' : ''}`}>
<div className={`flex items-center gap-1 ${sidebarCollapsed ? 'mx-auto flex-col' : ''}`}>
{loading && !sidebarCollapsed && (
<motion.div
animate={{ rotate: 360 }}
@@ -452,6 +626,15 @@ function WorkspacePage() {
<RefreshCw size={14} className="text-shell-500" />
</motion.div>
)}
{!sidebarCollapsed && (
<button
onClick={() => setNewFileDialogOpen(true)}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
title="New File"
>
<Plus size={16} className="text-shell-500 hover:text-neon-mint" />
</button>
)}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
@@ -497,7 +680,7 @@ function WorkspacePage() {
)
})}
{starredPaths.size > 5 && (
<span className="text-[10px] text-shell-500">+{starredPaths.size - 5}</span>
<span className="text-[11px] text-shell-500">+{starredPaths.size - 5}</span>
)}
</div>
) : (
@@ -519,7 +702,7 @@ function WorkspacePage() {
>
<FileText
size={14}
className={`flex-shrink-0 ${
className={`shrink-0 ${
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
}`}
/>
@@ -531,7 +714,7 @@ function WorkspacePage() {
e.stopPropagation()
handleStar(filePath)
}}
className="text-yellow-400 hover:text-yellow-300 flex-shrink-0"
className="text-yellow-400 hover:text-yellow-300 shrink-0"
title="Unstar file"
>
<Star size={14} fill="currentColor" />
@@ -544,17 +727,19 @@ function WorkspacePage() {
</>
)}
{/* File tree */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
{pathValid ? (
<FileTree
entries={rootEntries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
/>
) : (
{/* File tree */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
{pathValid ? (
<FileTree
entries={rootEntries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
onContextMenu={handleContextMenu}
onCreateFile={handleCreateFileInFolder}
/>
) : (
<div className="p-4 text-center">
<p className="font-console text-xs text-shell-500">
Enter a workspace path to browse files
@@ -564,10 +749,10 @@ function WorkspacePage() {
</div>
)}
{/* Sidebar footer */}
{/* Sidebar footer */}
{pathValid && !sidebarCollapsed && (
<div className="px-4 py-2 border-t border-shell-800">
<p className="font-console text-[10px] text-shell-600 truncate">
<p className="font-console text-[11px] text-shell-600 truncate">
{workspacePath}
</p>
</div>
@@ -575,9 +760,9 @@ function WorkspacePage() {
</motion.div>
)}
{/* Main content area */}
{/* Main content area */}
<div className={`flex-1 relative bg-shell-950 ${isMobile ? 'pb-20' : ''}`}>
<MarkdownViewer
<FileEditor
content={selectedFileContent}
fileName={selectedFileName}
filePath={selectedPath ?? undefined}
@@ -586,6 +771,7 @@ function WorkspacePage() {
error={fileError}
isStarred={selectedPath ? starredPaths.has(selectedPath) : false}
onStar={handleStar}
onSave={handleSave}
/>
</div>
</div>
@@ -627,6 +813,40 @@ function WorkspacePage() {
/>
</>
)}
{/* Delete confirmation dialog */}
<ConfirmationDialog
open={deleteConfirmOpen}
title="Delete File"
message={`Are you sure you want to delete "${fileToDelete?.split('/').pop()}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteFile}
onCancel={() => {
setDeleteConfirmOpen(false)
setFileToDelete(null)
}}
variant="danger"
/>
{/* New file dialog */}
<NewFileDialog
open={newFileDialogOpen}
onClose={() => {
setNewFileDialogOpen(false)
setNewFileFolderPath(null)
}}
onCreate={handleCreateFile}
folderPath={newFileFolderPath ?? undefined}
/>
{/* Context menu */}
<FileContextMenu
open={contextMenuOpen}
position={contextMenuPosition}
items={contextMenuItems}
onClose={() => setContextMenuOpen(false)}
/>
</div>
)
}