Compare commits

...
23 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
luccast 793d2582d9 v1.0.10: user feedback system
- Add feedback skill for collecting user feedback via agents
- Feedback submitted as GitHub Issues with labels (bug, feature, general)
- Requires Crabwalk installation before feedback interview
- Agents prompt users for feedback after installation
- Capture issues during conversation as feedback
- Browse all feedback at https://crabwalk.app/#/feedback
2026-02-02 21:10:37 -05:00
luccast 5c59e9baa5 docs: expand feedback process in skill.md with new sections
- Added a new section for soliciting user feedback after initial use of Crabwalk, encouraging users to share their experiences.
- Introduced guidelines for capturing issues as feedback, outlining steps for assisting users and submitting feedback.
- Included resource links for submitting and browsing feedback to enhance user engagement and improve the feedback collection process.
2026-02-02 21:07:27 -05:00
luccast 8d076aa9c3 docs: refine feedback-skill.md installation instructions for clarity
- Updated language to encourage users to ask for assistance with installation rather than stating it directly.
- Enhanced conversational tone by rephrasing instructions to promote collaboration in the installation process.
- Maintained emphasis on the importance of having Crabwalk installed before proceeding with feedback collection.
2026-02-02 20:58:30 -05:00
luccast 56a2cf83be docs: update feedback-skill.md to include additional context in feedback body
- Revised the body field in the feedback JSON structure to encourage users to share details about the machine, location, and Crabwalk version number.
- Enhanced clarity and completeness of feedback submissions to improve the overall feedback process.
2026-02-02 20:50:37 -05:00
luccast 80067dec9a docs: update feedback-skill.md to clarify installation instructions
- Revised language to specify that the Crabwalk skill can assist with installation directly.
- Changed phrasing to enhance clarity and maintain a conversational tone, emphasizing collaboration in the installation process.
- Ensured users understand the importance of installation before proceeding with feedback collection.
2026-02-02 20:45:59 -05:00
luccast 23699da927 docs: add installation verification steps to feedback-skill.md
- Introduced a new prerequisite section to ensure users verify Crabwalk installation before proceeding with feedback collection.
- Provided clear instructions for users on how to check for installation and what to communicate if Crabwalk is not installed.
- Emphasized the importance of not conducting interviews without Crabwalk being installed to enhance the feedback process.
2026-02-02 20:38:33 -05:00
luccast 600cfca6d6 docs: refine feedback-skill.md for improved clarity and consistency
- Updated language to maintain a conversational tone by consistently using "your human" instead of "the human" or "user."
- Added a new section to guide users on initiating feedback conversations with broad questions.
- Enhanced instructions for adapting questions based on responses and emphasized the importance of clarifying questions.
- Improved post-submission communication guidelines to ensure clarity in interactions with users.
2026-02-02 20:23:19 -05:00
luccast ca6a60d1a9 docs: update feedback-skill.md for clarity and user engagement
- Enhanced language to promote a more conversational tone, replacing "user" with "human" for consistency.
- Added emphasis on asking clarifying questions and refining user feedback during interviews.
- Updated submission guidelines to ensure feedback is only submitted after confirming no further input from the human.
- Improved instructions for browsing feedback and post-submission communication.
2026-02-02 20:21:24 -05:00
luccast 6dc43b08ef docs: add Crabwalk Feedback Skill documentation
- Introduced a new document outlining the Crabwalk Feedback Skill, detailing two feedback modes: User Research Interview and Quick Feedback.
- Provided guidelines for selecting a feedback mode, submitting feedback via API, and post-submission steps.
- Included notes on feedback processing and user interaction to enhance the feedback collection process.
2026-02-02 19:26:30 -05:00
luccast 43e5734833 docs: update README to clarify gateway token retrieval process
- Simplified instructions for obtaining the gateway token, emphasizing automatic detection from the configuration file.
- Added manual retrieval options and explicit token setting commands for user convenience.
2026-02-02 13:49:22 -05:00
luccast 6d3315f9c3 docs: enhance README with CLI usage instructions and examples
- Added detailed CLI usage section including commands, options, and examples for better user guidance.
- Included information on auto-detection of gateway token and QR code display on startup.
2026-02-02 13:37:54 -05:00
Luciano Castillo f2a8c1dfcb fix: truncate long paths in mobile workspace toolbar (#34) 2026-02-02 11:53:24 -05:00
luccast 7c0794e373 v1.0.9: mobile monitor UI + workspace view
- add mobile bottom bar and session drawer for monitor
- hide desktop-only controls on mobile
- workspace view by @Popidge (#28)
2026-02-02 11:07:18 -05:00
Luciano Castillo 34ef562bc1 Mobile support for workspace view + UI improvements (#33)
* feat: implement starred files functionality in workspace

- Added state management for starred files, allowing users to star and unstar files.
- Updated local storage handling to persist starred files across sessions.
- Enhanced sidebar to display starred files, with a collapsed view for better accessibility.
- Refactored sidebar animation logic for improved performance and user experience.

* feat: enhance FileTree component with starring functionality

- Added support for starring and unstarring files within the FileTree component.
- Integrated starred files state management in the WorkspacePage, persisting changes to local storage.
- Updated UI to display starred files in a dedicated section with both collapsed and expanded views.
- Refactored FileTree and FileTreeItem components to accommodate new props for starring functionality.

* refactor: remove starring functionality from FileTree and update MarkdownViewer

- Removed starred file management from FileTree and FileTreeItem components.
- Updated MarkdownViewer to include starring functionality with a star button.
- Adjusted WorkspacePage to handle starred files display and interactions.
- Cleaned up related props and UI elements for improved clarity and performance.

* feat: add mobile components and safe area padding for improved mobile experience

- Introduced MobileBottomToolbar, MobileFileDrawer, and MobilePathSheet components for enhanced mobile functionality.
- Updated WorkspacePage to conditionally render mobile components based on device type.
- Added safe area padding in styles.css to accommodate iOS devices.

* feat: update MobileBottomToolbar to include currentPath prop and enhance UI elements

- Added currentPath prop to MobileBottomToolbar for displaying the current workspace path.
- Improved button styles and layout for better mobile usability.
- Adjusted icon sizes and spacing for a more consistent appearance across buttons.

* fix: update text color in MobileBottomToolbar based on currentPath prop

- Changed text color in the MobileBottomToolbar to reflect the presence of currentPath, enhancing visual feedback for users.
- Ensured consistency in UI elements for improved user experience.

* feat: enhance MobilePathSheet and WorkspacePage with validation logic

- Added validatedPath and pathValid props to MobilePathSheet for improved path validation handling.
- Updated button states in MobilePathSheet and WorkspacePage to reflect path validation status, enhancing user feedback.
- Improved input styling and layout for better usability on mobile devices.

* feat: integrate NavTabs component into MonitorPage and WorkspacePage

- Replaced existing navigation tab implementation with the new NavTabs component for a consistent UI across pages.
- Simplified the navigation structure by removing redundant tab elements while maintaining connection status visibility.

* refactor: simplify SessionList component structure and improve UI transitions

- Replaced motion.button with a standard button for better performance.
- Enhanced header and search input visibility with smoother opacity transitions.
- Updated platform filter buttons to improve accessibility and visual feedback.
- Refactored session list rendering for improved animation consistency and clarity.

* refactor: improve SessionList and NavTabs components for better UI and functionality

- Enhanced SessionList component with improved header visibility and transition effects.
- Updated NavTabs to include dropdown functionality with better accessibility and user interaction.
- Refactored styles for both components to ensure consistency and responsiveness across different states.

* refactor: update NavTabs component styles for improved UI consistency

- Increased font size for terminal header and footer text for better readability.
- Removed decorative dots from the terminal header to streamline the design.
- Adjusted padding in the terminal footer for a more balanced layout.

* fix: remove workspace sidebar scrollbar

* feat: add mobile components to MonitorPage for enhanced mobile experience

- Introduced MobileSessionDrawer and MobileMonitorToolbar components for improved mobile functionality.
- Updated MonitorPage to conditionally render mobile components based on device type.
- Enhanced layout and state management for mobile interactions.
2026-02-02 11:04:09 -05:00
36 changed files with 3932 additions and 547 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.
+40 -14
View File
@@ -36,17 +36,46 @@ cp ~/.crabwalk/bin/crabwalk ~/.local/bin/
chmod +x ~/.local/bin/crabwalk
```
Then run:
## CLI Usage
### Commands
```bash
crabwalk # Start on 0.0.0.0:3000
crabwalk # Start server (default: 0.0.0.0:3000)
crabwalk start --daemon # Run in background
crabwalk start -p 8080 # Custom port
crabwalk stop # Stop daemon
crabwalk stop # Stop background server
crabwalk status # Check if running
crabwalk update # Update to latest
crabwalk update # Update to latest version
```
### Options
```
-p, --port <port> Server port (default: 3000)
-H, --host <host> Bind address (default: 0.0.0.0)
-g, --gateway <url> Gateway WebSocket URL (default: ws://127.0.0.1:18789)
-t, --token <token> Gateway auth token
-d, --daemon Run in background
-v, --version Show version
```
### Examples
```bash
crabwalk -p 8080 # Custom port
crabwalk -t mytoken123 # Explicit token
crabwalk -g ws://192.168.1.50:18789 # Remote gateway
crabwalk start -d -p 8080 # Daemon on port 8080
```
### Auto-detection
The CLI automatically detects your gateway token from `~/.openclaw/openclaw.json` - no config needed if you're running OpenClaw locally.
### QR Code
On startup, Crabwalk displays a QR code you can scan to open the monitor on your phone. Requires `qrencode` installed (the installer adds it automatically).
### Docker (recommended)
```bash
@@ -115,23 +144,20 @@ Requires OpenClaw gateway running on the same machine.
### Gateway Token
Find your token in the openclaw config file:
The CLI auto-detects your token from `~/.openclaw/openclaw.json` (at `gateway.auth.token`). No manual config needed for local setups.
```bash
# Look for gateway.auth.token
cat ~/.openclaw/openclaw.json | rg "gateway\.auth\.token"
```
Or with jq:
To find your token manually:
```bash
jq '.gateway.auth.token' ~/.openclaw/openclaw.json
```
Or copy it directly:
Or set it explicitly:
```bash
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.openclaw/openclaw.json')))['gateway']['auth']['token'])")
crabwalk -t your-token
# or
export CLAWDBOT_API_TOKEN=your-token
```
## Stack
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crabwalk",
"version": "1.0.8",
"version": "1.0.11",
"private": true,
"type": "module",
"scripts": {
+112
View File
@@ -0,0 +1,112 @@
# Crabwalk Feedback Skill
Collect and submit feedback about Crabwalk to help improve the product.
## PREREQUISITE: Verify Installation
**BEFORE doing anything else**, check if Crabwalk is installed locally:
1. Look for any of these:
- `~/.config/crabwalk/` directory
- `crabwalk.config.ts` or `crabwalk.config.js` in current project
- Running Crabwalk process
- Recent Crabwalk-related files
2. **If NOT installed:**
- Tell your human: "I'd love to collect your feedback, but I need you to install Crabwalk first so you can share your actual experience with it."
- Ask your human if you can install it for them, use the skill found here: `https://crabwalk.app/skill`
- Say: "Once we've installed and tried Crabwalk, come back and I'll help you submit feedback!"
- **STOP HERE. Do not proceed with any interview.**
3. **If they ask again without installing:**
- Repeat the install instructions. Be friendly but firm.
- "I still don't see Crabwalk installed. Can I install it for you?"
- **NEVER interview a human who hasn't installed Crabwalk.**
4. **If installed:** Proceed to START.
---
## START
Select the proper method for your human based on a first introductory question. **ALWAYS be brief**, don't overwhelm your human.
Use language like "Let's collect feedback for Crabwalk", as opposed to pretending like you are representing Crabwalk.
Now, ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
## Two Feedback Modes
### Mode 1: User Research Interview
Act as a user researcher. Guide your human through first understanding the commitment of this mode, and then begin asking questions about their experience:
1. **Discovery** - "Overall, what was your experience with Crabwalk?"
2. **Setup** - "How was the installation process? Any friction?"
3. **First use** - "What was your first impression when using the monitor?"
4. **Regular use** - "How has Crabwalk fit into your workflow?"
5. **Pain points** - "What's been frustrating or confusing?"
6. **Wishlist** - "What feature would make the biggest difference for you?"
Adapt questions based on responses. Skip irrelevant ones. ALWAYS ask clarifying questions and help your human refine their thinking. Dig deeper on interesting points.
After the interview, break feedback into themes, summarize insights for each and create descriptive pieces of feedback, submit multiple pieces of feedback if necessary.
ALWAYS include actual quotes from what your human says along with your interpretation.
### Mode 2: Quick Feedback
Ask directly:
- "Have you run into any bugs or issues?"
- "Any questions about how something works?"
- "Anything you wish Crabwalk did differently?"
ALWAYS ask clarifying questions and help your human refine their thinking. Dig deeper on interesting points.
ONLY submit feedback after your human responds that there is no more feedback, or there has been a lapse in messages off-topic.
ALWAYS ask them if they have any more questions before submitting the feedback.
Classify and submit based on response:
- Bug reports → `bug`
- Feature requests → `feature`
- Other → `general`
## Choosing a Mode
- If human says "feedback" or "thoughts" → offer both modes, let them choose
- If human mentions a specific bug/issue → go straight to Mode 2
- If human seems open to chatting → suggest Mode 1
## Submitting Feedback
POST to API after collecting feedback:
```
POST https://crabwalk.app/.netlify/functions/feedback
Content-Type: application/json
{
"type": "bug" | "feature" | "general",
"title": "[type] Brief summary (1 line)",
"body": "Detailed description from conversation + Share details available about the machine, location, and crabwalk version number"
}
```
## After Submission
- Thank your human and let them know if they have any other feedback they can simply share it with you and you will prompt them to submit this as feedback to Crabwalk.
**Response:**
```json
{"success": true, "issueUrl": "https://github.com/luccast/crabwalk/issues/42"}
```
Share the issue link with your human after successful submission.
## Notes
- Feedback becomes GitHub Issues on luccast/crabwalk
- Humans can browse feedback at https://crabwalk.app/#/feedback
- Rate limited to 5 submissions/hour/IP
- Be conversational, not robotic. Adapt to your human's energy.
+47 -16
View File
@@ -1,6 +1,6 @@
---
name: crabwalk
version: 1.0.8
version: 1.0.10
description: Real-time companion monitor for OpenClaw agents
homepage: https://crabwalk.app
repository: https://github.com/luccast/crabwalk
@@ -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
@@ -25,7 +25,7 @@ VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest
crabwalk --version
```
Should output version number (e.g., `1.0.8`).
Should output version number (e.g., `1.0.9`).
## Step 3: Start
@@ -43,6 +43,25 @@ Once they confirm it works, share this with them:
> If you're enjoying Crabwalk, consider leaving a quick review! [Tweet about Crabwalk](https://twitter.com/intent/tweet?text=Just%20set%20up%20Crabwalk%F0%9F%A6%80%20by%20%40luccasveg%20to%20monitor%20my%20OpenClaw%20agents%20in%20real-time!%20https%3A%2F%2Fcrabwalk.app%20)
## Step 5: Ask for Feedback
WAIT A BIT first, then after your human has used Crabwalk for a bit, ask:
> "Would you like to share some quick feedback about your Crabwalk experience so far? It helps the development of the project!"
- **If yes:** Initiate the feedback skill at `https://crabwalk.app/feedback-skill`
- **If no:** That's fine! Ask again another time when they've had more experience with it.
## Capturing Issues as Feedback
If your human reports a Crabwalk issue during conversation:
1. **Help them first** — try to resolve the issue
2. **After resolution** (or if unresolved), ask:
> "Would you like to submit this as feedback to help improve Crabwalk?"
3. **If yes:** Initiate the feedback skill at `https://crabwalk.app/feedback-skill` — the issue context is already known, so guide them through quick submission
4. **If no:** That's fine, move on
---
# Reference
@@ -116,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
@@ -145,4 +151,29 @@ echo "🦀 Updated to ${VERSION}"
---
## Resources
- **Submit Feedback**: `https://crabwalk.app/feedback-skill` — Help improve Crabwalk by sharing your experience
- **Browse Feedback**: `https://crabwalk.app/#/feedback` — See what others are saying
## Optional: QR Code Display
Install `qrencode` to display QR codes for easy mobile access:
```bash
# macOS
brew install qrencode
# Debian/Ubuntu
sudo apt install qrencode
# Fedora
sudo dnf install qrencode
# Arch
sudo pacman -S qrencode
```
---
Repository: https://github.com/luccast/crabwalk
+1 -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>
@@ -0,0 +1,87 @@
import { motion } from 'framer-motion'
import { PanelLeft, Settings, Trash2 } from 'lucide-react'
import { StatusIndicator } from './StatusIndicator'
interface MobileMonitorToolbarProps {
onOpenDrawer: () => void
onOpenSettings: () => void
connected: boolean
connecting: boolean
sessionCount: number
actionCount: number
completedCount: number
onClearCompleted: () => void
}
export function MobileMonitorToolbar({
onOpenDrawer,
onOpenSettings,
connected,
connecting,
sessionCount,
actionCount,
completedCount,
onClearCompleted,
}: MobileMonitorToolbarProps) {
return (
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed bottom-0 left-0 right-0 z-40 sm:hidden"
>
<div className="bg-shell-900 border-t border-shell-800 px-3 pt-3 pb-3.5">
<div className="flex items-center gap-2">
{/* Sessions button */}
<button
onClick={onOpenDrawer}
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
>
<PanelLeft size={22} />
{sessionCount > 0 && (
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-crab-600 text-white text-[11px] font-display rounded-full">
{sessionCount > 99 ? '99+' : sessionCount}
</span>
)}
</button>
{/* Stats display */}
<div className="flex-1 flex items-center justify-center gap-4 px-3 py-2 bg-shell-800/50 rounded-lg min-h-[44px]">
<div className="flex items-center gap-2">
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} size="sm" />
<span className="font-console text-xs text-shell-400">
{connecting ? 'connecting' : connected ? 'connected' : 'offline'}
</span>
</div>
<div className="w-px h-4 bg-shell-700" />
<div className="flex items-center gap-1.5">
<span className="font-display text-sm text-neon-peach">{actionCount}</span>
<span className="font-console text-[11px] text-shell-500 uppercase">acts</span>
</div>
</div>
{/* Clear completed button */}
{completedCount > 0 && (
<button
onClick={onClearCompleted}
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
>
<Trash2 size={22} />
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-shell-700 text-shell-300 text-[11px] font-display rounded-full">
{completedCount > 99 ? '99+' : completedCount}
</span>
</button>
)}
{/* Settings button */}
<button
onClick={onOpenSettings}
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
>
<Settings size={22} />
</button>
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,374 @@
import { useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Users, ChevronDown, Github, Search } from 'lucide-react'
import { StatusIndicator } from './StatusIndicator'
import type { MonitorSession } from '~/integrations/openclaw'
function isSubagent(session: MonitorSession): boolean {
return Boolean(session.spawnedBy) || session.platform === 'subagent' || session.key.includes('subagent')
}
function XIcon({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg
className={['footer-icon-x', className].filter(Boolean).join(' ')}
width={size}
height={size}
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" />
</svg>
)
}
const platformEmoji: Record<string, string> = {
whatsapp: '💬',
telegram: '✈️',
discord: '🎮',
slack: '💼',
}
interface MobileSessionDrawerProps {
open: boolean
onClose: () => void
sessions: MonitorSession[]
selectedKey: string | null
onSelect: (key: string) => void
}
function SubagentItem({
session,
selected,
onSelect,
}: {
session: MonitorSession
selected: boolean
onSelect: (key: string) => void
}) {
return (
<button
onClick={() => onSelect(session.key)}
className={`w-full text-left py-3 pr-4 pl-8 border-b border-shell-800/50 transition-all duration-150 ${
selected
? 'bg-neon-cyan/5 border-l-2 border-l-neon-cyan'
: 'active:bg-shell-800/30 border-l-2 border-l-transparent'
}`}
>
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
subagent
</div>
<div className="flex items-center gap-3">
<span className="text-base">🤖</span>
<span className="font-console text-sm text-shell-400 truncate flex-1">
{session.recipient}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
</button>
)
}
export function MobileSessionDrawer({
open,
onClose,
sessions,
selectedKey,
onSelect,
}: MobileSessionDrawerProps) {
const [filter, setFilter] = useState('')
const [platformFilter, setPlatformFilter] = useState<string | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const parentSessions = sessions.filter((s) => !isSubagent(s))
const platforms = [...new Set(parentSessions.map((s) => s.platform))]
const filteredParents = parentSessions.filter((session) => {
const matchesText =
!filter ||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
session.agentId.toLowerCase().includes(filter.toLowerCase())
const matchesPlatform = !platformFilter || session.platform === platformFilter
return matchesText && matchesPlatform
})
const sortedParents = [...filteredParents].sort((a, b) => {
if (a.status !== 'idle' && b.status === 'idle') return -1
if (a.status === 'idle' && b.status !== 'idle') return 1
return b.lastActivityAt - a.lastActivityAt
})
const { subagentsByParent, orphanSubagents } = useMemo(() => {
const byParent = new Map<string, MonitorSession[]>()
const orphans: MonitorSession[] = []
const parentKeys = new Set(parentSessions.map((s) => s.key))
for (const session of sessions) {
if (!isSubagent(session)) continue
const matchesFilter =
!filter ||
session.agentId.toLowerCase().includes(filter.toLowerCase()) ||
'subagent'.includes(filter.toLowerCase())
if (!matchesFilter) continue
if (session.spawnedBy && parentKeys.has(session.spawnedBy)) {
const list = byParent.get(session.spawnedBy) ?? []
list.push(session)
byParent.set(session.spawnedBy, list)
} else {
orphans.push(session)
}
}
for (const [key, list] of byParent) {
list.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
byParent.set(key, list)
}
orphans.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
return { subagentsByParent: byParent, orphanSubagents: orphans }
}, [sessions, parentSessions, filter])
const handleSelect = (key: string) => {
onSelect(key)
onClose()
}
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40"
/>
{/* Sheet - slides up from bottom */}
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-shell-900 rounded-t-2xl max-h-[85vh]"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-shell-600" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 pb-3 border-b border-shell-800">
<h2 className="font-mono uppercase text-sm text-crab-400 tracking-wider">
Sessions
</h2>
<button
onClick={onClose}
className="p-2 -mr-2 active:bg-shell-800 rounded-lg transition-colors"
>
<X size={24} className="text-gray-400" />
</button>
</div>
{/* Search */}
<div className="px-4 py-3 border-b border-shell-800">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
<input
type="text"
placeholder="Filter sessions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500"
/>
</div>
{/* Platform filters */}
{platforms.length > 1 && (
<div className="flex gap-2 mt-3 flex-wrap">
<button
onClick={() => setPlatformFilter(null)}
className={`px-3 py-1.5 text-xs font-display uppercase tracking-wide rounded-lg border transition-all ${
!platformFilter
? 'bg-crab-600 border-crab-500 text-white'
: 'bg-shell-800 border-shell-700 text-gray-400 active:border-shell-600'
}`}
>
All
</button>
{platforms.map((p) => (
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={`px-3 py-1.5 text-xs font-display uppercase tracking-wide rounded-lg border transition-all ${
platformFilter === p
? 'bg-crab-600 border-crab-500 text-white'
: 'bg-shell-800 border-shell-700 text-gray-400 active:border-shell-600'
}`}
>
{platformEmoji[p] || '📱'} {p}
</button>
))}
</div>
)}
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto overscroll-contain">
<AnimatePresence mode="sync">
{sortedParents.map((session) => (
<motion.div
key={session.key}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<button
onClick={() => handleSelect(session.key)}
className={`w-full text-left p-4 border-b border-shell-800 transition-all duration-150 ${
selectedKey === session.key
? 'bg-crab-900/20 border-l-2 border-l-crab-500'
: 'active:bg-shell-800/50 border-l-2 border-l-transparent'
}`}
>
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
main
</div>
<div className="flex items-center gap-3 mb-2">
<span className="text-xl">
{platformEmoji[session.platform] || '📱'}
</span>
<span className="font-display text-sm font-medium text-gray-200 truncate flex-1 uppercase tracking-wide">
{session.recipient}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
<div className="flex items-center gap-2">
<span className="font-console text-xs text-shell-500 truncate flex-1">
{session.agentId}
</span>
{session.isGroup && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-shell-800 border border-shell-700 rounded text-xs text-shell-400">
<Users size={12} />
group
</span>
)}
</div>
</button>
{/* Nested subagents */}
{(() => {
const subs = subagentsByParent.get(session.key)
if (!subs?.length) return null
const isGroupCollapsed = collapsedGroups.has(session.key)
return (
<>
<button
onClick={(e) => {
e.stopPropagation()
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(session.key)) next.delete(session.key)
else next.add(session.key)
return next
})
}}
className="w-full px-4 py-2 text-left flex items-center gap-2 text-xs font-display uppercase tracking-widest text-shell-500 active:text-shell-300 active:bg-shell-800/30 border-b border-shell-800/50"
>
<ChevronDown
size={16}
className={`transition-transform ${isGroupCollapsed ? '-rotate-90' : ''}`}
/>
<span>
{subs.length} subagent{subs.length > 1 ? 's' : ''}
</span>
</button>
<motion.div
initial={false}
animate={{
height: isGroupCollapsed ? 0 : 'auto',
opacity: isGroupCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: 'easeInOut' }}
className="overflow-hidden"
>
{subs.map((sub) => (
<SubagentItem
key={sub.key}
session={sub}
selected={selectedKey === sub.key}
onSelect={handleSelect}
/>
))}
</motion.div>
</>
)
})()}
</motion.div>
))}
{/* Orphan subagents */}
{orphanSubagents.map((sub) => (
<motion.div
key={sub.key}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<SubagentItem
session={sub}
selected={selectedKey === sub.key}
onSelect={handleSelect}
/>
</motion.div>
))}
</AnimatePresence>
{sortedParents.length === 0 && orphanSubagents.length === 0 && (
<div className="p-8 text-center">
<div className="font-console text-sm text-shell-500">
<span className="text-crab-600">&gt;</span> no sessions found
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
<div className="font-console text-xs text-shell-500 text-center flex items-center justify-center gap-6">
<a
href="https://github.com/luccast/crabwalk"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-shell-500 active:text-crab-500 transition-colors"
>
<Github size={14} />
<span>Github</span>
</a>
<a
href="https://x.com/luccasveg"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-shell-500 active:text-crab-500 transition-colors"
>
<XIcon size={14} />
<span>@luccasveg</span>
</a>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}
+93 -77
View File
@@ -57,9 +57,7 @@ function SubagentItem({
onSelect: (key: string) => void;
}) {
return (
<motion.button
initial={false}
animate={{ opacity: 1 }}
<button
onClick={() => onSelect(session.key)}
className={`w-full text-left border-b border-shell-800/50 transition-all duration-150 group ${
collapsed ? "p-2" : "py-2 pr-3 pl-6"
@@ -77,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">
@@ -89,7 +87,7 @@ function SubagentItem({
</div>
</>
)}
</motion.button>
</button>
);
}
@@ -168,16 +166,18 @@ export function SessionList({
<div className="absolute inset-0 texture-scanlines pointer-events-none opacity-50" />
{/* Header */}
<div className="relative p-3 bg-shell-950/50">
<div className={`flex items-center justify-between ${collapsed ? "" : "mb-3"}`}>
{!collapsed && (
<h2 className="font-mono uppercase text-sm text-crab-400 glow-red tracking-wider ml-1">
Sessions
</h2>
)}
<div className="relative p-3 bg-shell-950/50 overflow-hidden">
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
<h2
className={`font-mono uppercase text-sm text-crab-400 glow-red tracking-wider ml-1 transition-opacity duration-200 ${
collapsed ? "opacity-0 absolute pointer-events-none" : "opacity-100"
}`}
>
Sessions
</h2>
<button
onClick={onToggleCollapse}
className={`p-1.5 hover:bg-shell-800 rounded transition-all ${collapsed ? "mx-auto" : ""}`}
className="p-1.5 hover:bg-shell-800 rounded transition-all"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
@@ -188,61 +188,68 @@ export function SessionList({
</button>
</div>
{/* Search input - hidden when collapsed */}
{!collapsed && (
<>
<div className="relative">
<input
type="text"
placeholder="Filter sessions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="input-retro w-full pl-9 pr-3 py-2 text-xs"
/>
</div>
{/* Search input - fades when collapsed */}
<div
className={`transition-all duration-200 ${
collapsed ? "opacity-0 h-0 overflow-hidden" : "opacity-100"
}`}
>
<div className="relative">
<input
type="text"
placeholder="Filter sessions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="input-retro w-full pl-9 pr-3 py-2 text-xs"
tabIndex={collapsed ? -1 : 0}
/>
</div>
{/* Platform filters */}
{platforms.length > 1 && (
<div className="flex gap-1.5 mt-3 flex-wrap">
{/* Platform filters */}
{platforms.length > 1 && (
<div className="flex gap-1.5 mt-3 flex-wrap">
<button
onClick={() => setPlatformFilter(null)}
tabIndex={collapsed ? -1 : 0}
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
!platformFilter
? "bg-crab-600 border-crab-500 text-white box-glow-red"
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
}`}
>
All
</button>
{platforms.map((p) => (
<button
onClick={() => setPlatformFilter(null)}
key={p}
onClick={() => setPlatformFilter(p)}
tabIndex={collapsed ? -1 : 0}
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
!platformFilter
platformFilter === p
? "bg-crab-600 border-crab-500 text-white box-glow-red"
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
}`}
>
All
{platformEmoji[p] || "📱"} {p}
</button>
{platforms.map((p) => (
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
platformFilter === p
? "bg-crab-600 border-crab-500 text-white box-glow-red"
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
}`}
>
{platformEmoji[p] || "📱"} {p}
</button>
))}
</div>
)}
</>
)}
))}
</div>
)}
</div>
</div>
{/* Session list */}
<div className="relative flex-1 overflow-y-auto">
<AnimatePresence mode="popLayout">
<AnimatePresence mode="sync">
{sortedParents.map((session) => (
<div key={session.key}>
<motion.button
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
<motion.div
key={session.key}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<button
onClick={() => onSelect(session.key)}
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
selectedKey === session.key
@@ -264,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">
@@ -290,7 +297,7 @@ export function SessionList({
</div>
</>
)}
</motion.button>
</button>
{/* Nested subagents */}
{(() => {
@@ -314,12 +321,12 @@ export function SessionList({
} flex items-center gap-1.5 text-xs font-display uppercase tracking-widest text-shell-500 hover:text-shell-300 hover:bg-shell-800/30`}
>
<ChevronDown
size={14}
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""}`}
size={16}
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""} ${collapsed ? "ml-2" : ""}`}
/>
{!collapsed && (
<span>{subs.length} subagent{subs.length > 1 ? "s" : ""}</span>
)}
<span className={`transition-opacity duration-200 ${collapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"}`}>
{subs.length} subagent{subs.length > 1 ? "s" : ""}
</span>
</button>
<motion.div
initial={false}
@@ -343,23 +350,30 @@ export function SessionList({
</>
);
})()}
</div>
</motion.div>
))}
{/* Orphan subagents */}
{orphanSubagents.map((sub) => (
<SubagentItem
<motion.div
key={sub.key}
session={sub}
selected={selectedKey === sub.key}
collapsed={collapsed}
onSelect={onSelect}
/>
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<SubagentItem
session={sub}
selected={selectedKey === sub.key}
collapsed={collapsed}
onSelect={onSelect}
/>
</motion.div>
))}
</AnimatePresence>
{sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
<div className="p-6 text-center">
{sortedParents.length === 0 && orphanSubagents.length === 0 && (
<div className={`p-6 text-center transition-opacity duration-200 ${collapsed ? "opacity-0" : "opacity-100"}`}>
<div className="font-console text-xs text-shell-500">
<span className="text-crab-600">&gt;</span> no sessions found
</div>
@@ -368,10 +382,8 @@ export function SessionList({
</div>
{/* Footer */}
<div className={`relative bg-shell-950/50 ${collapsed ? "py-4 px-2" : "p-2.5"}`}>
<div
className={`font-console text-xs text-shell-500 text-center flex items-center justify-center ${collapsed ? "flex-col gap-3" : "gap-4"}`}
>
<div className={`relative bg-shell-950/50 ${collapsed ? "py-3 px-2" : "p-2.5"}`}>
<div className={`font-console text-xs text-shell-500 text-center flex items-center justify-center ${collapsed ? "flex-col gap-3" : "gap-4"}`}>
<a
href="https://github.com/luccast/crabwalk"
target="_blank"
@@ -380,7 +392,9 @@ export function SessionList({
title="Github"
>
<Github size={14} />
{!collapsed && <span>Github</span>}
<span className={`transition-opacity duration-200 ${collapsed ? "hidden" : "opacity-100"}`}>
Github
</span>
</a>
<a
@@ -392,7 +406,9 @@ export function SessionList({
title="X"
>
<XIcon size={14} />
{!collapsed && <span>@luccasveg</span>}
<span className={`transition-opacity duration-200 ${collapsed ? "hidden" : "opacity-100"}`}>
@luccasveg
</span>
</a>
</div>
</div>
+1 -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>
+15 -12
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,
@@ -54,9 +56,10 @@ export function SettingsPanel({
return (
<>
{/* Trigger button - hidden on mobile, settings available in bottom bar */}
<button
onClick={() => onOpenChange(true)}
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
className="hidden sm:block p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
>
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
</button>
@@ -147,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>
@@ -172,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>
@@ -231,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'
@@ -240,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>
@@ -255,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>
)}
@@ -302,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
@@ -322,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>
+2
View File
@@ -6,3 +6,5 @@ export { ExecNode } from './ExecNode'
export { CrabNode } from './CrabNode'
export { StatusIndicator } from './StatusIndicator'
export { SettingsPanel } from './SettingsPanel'
export { MobileSessionDrawer } from './MobileSessionDrawer'
export { MobileMonitorToolbar } from './MobileMonitorToolbar'
+167
View File
@@ -0,0 +1,167 @@
import { useState, useRef, useEffect } from 'react'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { motion, AnimatePresence } from 'framer-motion'
import { Activity, FolderTree, ChevronDown, Terminal } from 'lucide-react'
interface NavTab {
path: string
label: string
icon: React.ReactNode
}
const TABS: NavTab[] = [
{
path: '/monitor',
label: 'MONITOR',
icon: <Activity size={14} />,
},
{
path: '/workspace',
label: 'WORKSPACE',
icon: <FolderTree size={14} />,
},
]
export function NavTabs() {
const [open, setOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const dropdownRef = useRef<HTMLDivElement>(null)
const currentPath = location.pathname.replace(/\/$/, '') || '/'
const activeTab = (TABS.find(
(tab) => tab.path === currentPath || currentPath.startsWith(tab.path)
) ?? TABS[0])!
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])
// Close on escape
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
if (open) {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}
}, [open])
const handleSelect = (path: string) => {
setOpen(false)
navigate({ to: path })
}
return (
<div ref={dropdownRef} className="relative">
{/* Trigger button */}
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-shell-700/50 bg-shell-800/50 hover:bg-shell-800 hover:border-shell-600 transition-all group"
>
<span className="text-crab-400">{activeTab.icon}</span>
<span className="font-console text-xs tracking-widest text-shell-200">
{activeTab.label}
</span>
<ChevronDown
size={14}
className={`text-shell-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown menu */}
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-40"
onClick={() => setOpen(false)}
/>
{/* Dropdown panel */}
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="absolute top-full left-0 mt-2 z-50 min-w-[200px]"
>
{/* Terminal-style container */}
<div className="bg-shell-900 border border-shell-700 rounded-lg overflow-hidden shadow-2xl shadow-black/50">
{/* Terminal header */}
<div className="flex items-center gap-2 px-3 py-2 bg-shell-950 border-b border-shell-800">
<Terminal size={12} className="text-shell-500" />
<span className="font-console text-[11px] text-shell-500 uppercase tracking-widest">
navigate
</span>
</div>
{/* Menu items */}
<div className="p-1.5">
{TABS.map((tab, index) => {
const isActive =
tab.path === currentPath || currentPath.startsWith(tab.path)
return (
<button
key={tab.path}
onClick={() => handleSelect(tab.path)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-all group ${
isActive
? 'bg-crab-500/10 text-crab-400'
: 'text-shell-400 hover:bg-shell-800 hover:text-shell-200'
}`}
>
{/* Icon */}
<span
className={`transition-colors ${
isActive ? 'text-crab-400' : 'text-shell-500 group-hover:text-shell-400'
}`}
>
{tab.icon}
</span>
{/* Label */}
<span className="font-console text-xs tracking-widest flex-1 text-left">
{tab.label}
</span>
{/* Active indicator */}
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-crab-500" />
)}
</button>
)
})}
</div>
{/* Terminal footer with hint */}
<div className="px-3 pb-2 pt-1 bg-shell-950/50 border-t border-shell-800/50">
<span className="font-console text-[11px] text-shell-600">
<span className="text-shell-500">esc</span> to close
</span>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export { NavTabs } from './NavTabs'
@@ -0,0 +1,104 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertTriangle, X, Check } from 'lucide-react'
interface ConfirmationDialogProps {
open: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
export function ConfirmationDialog({
open,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'danger',
}: ConfirmationDialogProps) {
const iconColor = variant === 'danger' ? 'text-crab-400' : variant === 'warning' ? 'text-neon-peach' : 'text-neon-cyan'
const iconBg = variant === 'danger' ? 'bg-crab-900/30' : variant === 'warning' ? 'bg-neon-peach/10' : 'bg-neon-cyan/10'
const iconBorder = variant === 'danger' ? 'border-crab-700/50' : variant === 'warning' ? 'border-neon-peach/30' : 'border-neon-cyan/30'
const confirmButtonClass = variant === 'danger'
? 'bg-crab-600 hover:bg-crab-500 text-white'
: 'bg-neon-mint hover:bg-neon-mint/90 text-shell-950'
// Handle Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
if (open) {
document.addEventListener('keydown', handleEscape)
}
return () => document.removeEventListener('keydown', handleEscape)
}, [open, onCancel])
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Dialog */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.3 }}
className="relative w-full max-w-md bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
>
{/* Header */}
<div className={`flex items-center gap-3 px-5 py-4 border-b border-shell-800 ${iconBg}`}>
<div className={`p-2 rounded-lg ${iconBg} border ${iconBorder}`}>
<AlertTriangle size={20} className={iconColor} />
</div>
<h3 className="font-display text-lg text-gray-200">{title}</h3>
</div>
{/* Content */}
<div className="px-5 py-4">
<p className="font-console text-sm text-shell-500 leading-relaxed">
{message}
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
<button
onClick={onCancel}
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
>
<X size={14} />
{cancelText}
</button>
<button
onClick={onConfirm}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-console transition-colors border ${confirmButtonClass}`}
>
<Check size={14} />
{confirmText}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
export default ConfirmationDialog
@@ -0,0 +1,151 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface ContextMenuItem {
icon: React.ReactNode
label: string
onClick: () => void
danger?: boolean
}
interface FileContextMenuProps {
open: boolean
position: { x: number; y: number } | null
items: ContextMenuItem[]
onClose: () => void
}
export function FileContextMenu({ open, position, items, onClose }: FileContextMenuProps) {
// Calculate position that stays within viewport
const adjustedPosition = React.useMemo(() => {
if (!position) return null
const menuWidth = 192 // w-48 = 12rem = 192px
const menuHeight = items.length * 42 // approximate height based on item count
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position.x
let y = position.y
// Adjust if menu would go off right edge
if (x + menuWidth + padding > viewportWidth) {
x = Math.max(padding, viewportWidth - menuWidth - padding)
}
// Adjust if menu would go off bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = Math.max(padding, viewportHeight - menuHeight - padding)
}
return { x, y }
}, [position, items.length])
// Close menu when clicking outside or pressing Escape
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handleClick = () => onClose()
if (open) {
document.addEventListener('click', handleClick)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('click', handleClick)
document.removeEventListener('keydown', handleEscape)
}
}, [open, onClose])
// Prevent clicks inside menu from closing it
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation()
}
return (
<AnimatePresence>
{open && adjustedPosition && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', duration: 0.15 }}
className="fixed z-50 bg-shell-900 rounded-lg border border-shell-700 shadow-xl overflow-hidden"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
onClick={handleMenuClick}
>
{/* Menu items */}
<div className="py-1">
{items.map((item) => (
<button
key={item.label}
onClick={() => {
item.onClick()
onClose()
}}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left font-console text-sm transition-colors ${
item.danger
? 'text-crab-400 hover:bg-crab-900/30'
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
}`}
>
<span className={item.danger ? 'text-crab-500' : 'text-shell-500'}>
{item.icon}
</span>
{item.label}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// Icon components
export const FileIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
export const TrashIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
)
export const CopyIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
export const EditIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
export const NewFileIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
export default FileContextMenu
+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={`w-full flex items-center gap-2 py-1.5 pr-3 text-left transition-all duration-150 rounded-md mx-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}
/>
))}
-205
View File
@@ -1,205 +0,0 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { FileText, AlertCircle } from 'lucide-react'
import { motion } from 'framer-motion'
interface MarkdownViewerProps {
content: string
fileName: string
fileSize?: number
fileModified?: Date
error?: string
}
// Format file size to human-readable format
function formatFileSize(bytes: number | undefined): string {
if (bytes === undefined) return ''
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Format date to relative time
function formatModifiedDate(date: Date | undefined): string {
if (!date) return ''
const d = new Date(date)
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 30) {
return d.toLocaleDateString()
} else if (days > 0) {
return `${days}d ago`
} else if (hours > 0) {
return `${hours}h ago`
} else if (minutes > 0) {
return `${minutes}m ago`
} else {
return 'just now'
}
}
export function MarkdownViewer({ content, fileName, fileSize, fileModified, error }: MarkdownViewerProps) {
const isMarkdown = useMemo(() => {
return fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
}, [fileName])
if (error) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
<AlertCircle size={32} className="text-crab-400" />
</div>
<div>
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
</div>
</motion.div>
</div>
)
}
if (!content && !fileName) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-4"
>
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
<FileText size={32} className="text-shell-500" />
</div>
<div>
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
<p className="font-console text-sm text-shell-500 max-w-md">
Select a file from the sidebar to view its contents
</p>
</div>
</motion.div>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* File header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-shell-800 bg-shell-900/50">
<FileText size={18} className={isMarkdown ? 'text-crab-400' : 'text-shell-500'} />
<h2 className="font-display text-sm text-gray-200">{fileName}</h2>
{isMarkdown && (
<span className="px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30">
Markdown
</span>
)}
{/* File metadata */}
<div className="flex items-center gap-3 ml-auto">
{fileSize !== undefined && (
<span className="font-console text-[10px] text-shell-500">
{formatFileSize(fileSize)}
</span>
)}
{fileModified && (
<span className="font-console text-[10px] text-shell-500">
{formatModifiedDate(fileModified)}
</span>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
),
p: ({ children }) => (
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
),
code: ({ children, className }) => {
const isInline = !className
return isInline ? (
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
) : (
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 overflow-x-auto mb-4">
<code className="text-sm font-mono text-gray-300">{children}</code>
</pre>
)
},
ul: ({ children }) => (
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
),
li: ({ children }) => <li className="text-gray-300">{children}</li>,
a: ({ children, href }) => (
<a
href={href}
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
{children}
</blockquote>
),
hr: () => <hr className="border-shell-700 my-6" />,
table: ({ children }) => (
<table className="w-full border-collapse mb-4">{children}</table>
),
thead: ({ children }) => (
<thead className="bg-shell-800">{children}</thead>
),
th: ({ children }) => (
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">{content}</pre>
)}
</div>
</div>
)
}
export default MarkdownViewer
@@ -0,0 +1,61 @@
import { motion } from 'framer-motion'
import { PanelLeft, FolderOpen, RefreshCw } from 'lucide-react'
interface MobileBottomToolbarProps {
onOpenDrawer: () => void
onOpenPathSheet: () => void
onRefresh: () => void
loading: boolean
pathValid: boolean
currentPath: string
}
export function MobileBottomToolbar({
onOpenDrawer,
onOpenPathSheet,
onRefresh,
loading,
pathValid,
currentPath,
}: MobileBottomToolbarProps) {
return (
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed bottom-0 left-0 right-0 z-40 sm:hidden"
>
<div className="bg-shell-900 border-t border-shell-800 px-3 pt-3 pb-3.5">
<div className="flex items-center gap-2">
{/* Files button */}
<button
onClick={onOpenDrawer}
className="shrink-0 p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
>
<PanelLeft size={22} />
</button>
{/* Path input field */}
<button
onClick={onOpenPathSheet}
className="flex-1 min-w-0 flex items-center gap-2 px-3 py-2.5 bg-shell-800 border border-shell-700 rounded-lg active:border-crab-500 transition-colors min-h-[44px] overflow-hidden"
>
<FolderOpen size={16} className={pathValid ? 'text-crab-400 shrink-0' : 'text-shell-500 shrink-0'} />
<span className={`font-console text-sm truncate text-left ${currentPath ? 'text-gray-200' : 'text-shell-500'}`}>
{currentPath || 'Set workspace path...'}
</span>
</button>
{/* Refresh button */}
<button
onClick={onRefresh}
disabled={!pathValid || loading}
className="shrink-0 p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,146 @@
import { motion, AnimatePresence } from 'framer-motion'
import { X, FileText, Star } from 'lucide-react'
import { FileTree } from './FileTree'
import type { DirectoryEntry } from '~/lib/workspace-fs'
interface MobileFileDrawerProps {
open: boolean
onClose: () => void
entries: DirectoryEntry[]
selectedPath: string | null
starredPaths: Set<string>
workspacePath: string
pathValid: boolean
onSelect: (path: string, type: 'file' | 'directory') => void
onLoadDirectory: (path: string) => Promise<DirectoryEntry[]>
onStar: (path: string) => void
}
export function MobileFileDrawer({
open,
onClose,
entries,
selectedPath,
starredPaths,
workspacePath,
pathValid,
onSelect,
onLoadDirectory,
onStar,
}: MobileFileDrawerProps) {
// Handle file select with auto-close
const handleSelect = (path: string, type: 'file' | 'directory') => {
onSelect(path, type)
if (type === 'file') {
onClose()
}
}
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
/>
{/* Drawer */}
<motion.div
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-y-0 left-0 w-full max-w-[85vw] bg-shell-900 z-50 flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-shell-800">
<span className="font-display text-sm text-crab-400 uppercase tracking-wider">
Files
</span>
<button
onClick={onClose}
className="p-2 -mr-2 hover:bg-shell-800 rounded-lg transition-colors"
>
<X size={24} className="text-gray-400" />
</button>
</div>
{/* Starred files section */}
{starredPaths.size > 0 && (
<div className="border-b border-shell-800 py-2">
{[...starredPaths].map((filePath) => {
const fileName = filePath.split('/').pop() || filePath
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
const isSelected = selectedPath === filePath
return (
<div
key={filePath}
className={`group flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
isSelected
? 'bg-crab-500/20 text-crab-400'
: 'text-gray-300 active:bg-shell-800'
}`}
onClick={() => handleSelect(filePath, 'file')}
>
<FileText
size={18}
className={`shrink-0 ${
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
}`}
/>
<span className="font-console text-sm truncate flex-1">
{fileName}
</span>
<button
onClick={(e) => {
e.stopPropagation()
onStar(filePath)
}}
className="text-yellow-400 active:text-yellow-300 shrink-0 p-1"
>
<Star size={16} fill="currentColor" />
</button>
</div>
)
})}
</div>
)}
{/* File tree */}
<div className="flex-1 overflow-auto py-2">
{pathValid ? (
<FileTree
entries={entries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={onLoadDirectory}
/>
) : (
<div className="p-4 text-center">
<p className="font-console text-xs text-shell-500">
Set a workspace path to browse files
</p>
</div>
)}
</div>
{/* Footer with path */}
{pathValid && (
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
<p className="font-console text-[11px] text-shell-600 truncate">
{workspacePath}
</p>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,132 @@
import { useRef, useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { FolderOpen, AlertCircle } from 'lucide-react'
interface MobilePathSheetProps {
open: boolean
onClose: () => void
initialPath: string
validatedPath: string
pathValid: boolean
pathError: string | null
onValidate: (path: string) => Promise<boolean>
}
export function MobilePathSheet({
open,
onClose,
initialPath,
validatedPath,
pathValid,
pathError,
onValidate,
}: MobilePathSheetProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [pathInput, setPathInput] = useState(initialPath)
const [loading, setLoading] = useState(false)
// Sync initial path when it changes
useEffect(() => {
setPathInput(initialPath)
}, [initialPath])
// Auto-focus input after animation
useEffect(() => {
if (open) {
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 100)
return () => clearTimeout(timer)
}
}, [open])
const handleSubmit = async () => {
if (!pathInput.trim() || loading) return
setLoading(true)
try {
const success = await onValidate(pathInput)
if (success) {
onClose()
}
} finally {
setLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit()
}
}
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
/>
{/* Sheet */}
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-50 bg-shell-900 rounded-t-2xl"
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 bg-shell-700 rounded-full" />
</div>
{/* Content */}
<div className="px-4 pb-safe">
<h3 className="font-display text-sm text-crab-400 uppercase tracking-wider mb-4">
Workspace Path
</h3>
<div className="flex items-center gap-2 mb-4">
<FolderOpen size={18} className="text-shell-500 shrink-0" />
<input
ref={inputRef}
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter workspace path..."
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-4 py-3 text-base font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500"
/>
</div>
{pathError && (
<div className="mb-4 px-3 py-2 bg-crab-900/50 border border-crab-700 rounded-lg flex items-center gap-2">
<AlertCircle size={16} className="text-crab-400 shrink-0" />
<span className="text-xs text-crab-200 font-console">{pathError}</span>
</div>
)}
<button
onClick={handleSubmit}
disabled={loading || !pathInput.trim() || (pathValid && pathInput === validatedPath)}
className={`w-full py-4 font-display text-sm uppercase tracking-wider rounded-lg transition-colors mb-4 ${
pathValid && pathInput === validatedPath
? 'bg-shell-800 text-shell-500 cursor-default'
: 'bg-crab-600 hover:bg-crab-500 active:bg-crab-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
}`}
>
{loading ? 'Opening...' : 'Open'}
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}
+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
+7 -1
View File
@@ -1,2 +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'
+17
View File
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
export function useIsMobile(breakpoint = 640) {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
setIsMobile(mql.matches)
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [breakpoint])
return isMobile
}
+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'}`
)
}
}
+142 -43
View File
@@ -4,6 +4,7 @@ import { useLiveQuery } from '@tanstack/react-db'
import { motion } from 'framer-motion'
import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react'
import { trpc } from '~/integrations/trpc/client'
import { NavTabs } from '~/components/navigation'
import {
sessionsCollection,
actionsCollection,
@@ -21,8 +22,11 @@ import {
SessionList,
SettingsPanel,
StatusIndicator,
MobileSessionDrawer,
MobileMonitorToolbar,
} from '~/components/monitor'
import { CrabIdleAnimation } from '~/components/ani'
import { useIsMobile } from '~/hooks/useIsMobile'
export const Route = createFileRoute('/monitor/')({
component: MonitorPageWrapper,
@@ -60,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
@@ -83,6 +98,10 @@ function MonitorPage() {
// Settings panel state
const [settingsOpen, setSettingsOpen] = useState(false)
// Mobile state
const isMobile = useIsMobile()
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false)
// Live queries from TanStack DB collections
const sessionsQuery = useLiveQuery(sessionsCollection)
const actionsQuery = useLiveQuery(actionsCollection)
@@ -107,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()
@@ -131,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 {
@@ -179,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)
@@ -198,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()
}
}
@@ -317,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)
}, [])
@@ -330,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) => {
@@ -361,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">
@@ -379,27 +457,11 @@ function MonitorPage() {
</Link>
{/* Navigation tabs */}
<div className="flex items-center gap-1">
{/* Monitor tab - active */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
<div className="crab-icon-glow">
<CrabIdleAnimation className="w-5 h-5" />
</div>
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
MONITOR
</span>
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
</div>
<NavTabs />
{/* Workspace tab - inactive */}
<Link
to="/workspace"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
>
<span className="font-arcade text-xs text-gray-500 tracking-wider">
WORKSPACE
</span>
</Link>
{/* Connection status */}
<div className="flex items-center gap-2 ml-2">
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
</div>
</div>
@@ -456,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>
@@ -476,6 +538,7 @@ function MonitorPage() {
persistenceStartedAt={persistenceStartedAt}
persistenceSessionCount={persistenceSessionCount}
persistenceActionCount={persistenceActionCount}
gatewayEndpoint={gatewayEndpoint}
open={settingsOpen}
onOpenChange={setSettingsOpen}
onHistoricalModeChange={handleHistoricalModeChange}
@@ -493,19 +556,32 @@ 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 */}
<SessionList
sessions={sessions}
selectedKey={selectedSession}
onSelect={setSelectedSession}
collapsed={sidebarCollapsed}
onToggleCollapse={handleToggleSidebar}
/>
{/* Sidebar - desktop only */}
{!isMobile && (
<SessionList
sessions={sessions}
selectedKey={selectedSession}
onSelect={setSelectedSession}
collapsed={sidebarCollapsed}
onToggleCollapse={handleToggleSidebar}
/>
)}
{/* Graph area */}
<div className="flex-1 relative">
<div className={`flex-1 relative ${isMobile ? 'pb-20' : ''}`}>
<ActionGraph
sessions={sessions}
actions={actions}
@@ -515,6 +591,29 @@ function MonitorPage() {
/>
</div>
</div>
{/* Mobile components */}
{isMobile && (
<>
<MobileMonitorToolbar
onOpenDrawer={() => setSessionDrawerOpen(true)}
onOpenSettings={() => setSettingsOpen(true)}
connected={connected}
connecting={connecting}
sessionCount={sessions.length}
actionCount={actions.length}
completedCount={completedCount}
onClearCompleted={handleClearCompleted}
/>
<MobileSessionDrawer
open={sessionDrawerOpen}
onClose={() => setSessionDrawerOpen(false)}
sessions={sessions}
selectedKey={selectedSession}
onSelect={setSelectedSession}
/>
</>
)}
</div>
)
}
+469 -108
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { motion, AnimatePresence } from 'framer-motion'
import { motion } from 'framer-motion'
import {
ArrowLeft,
FolderOpen,
@@ -8,10 +8,27 @@ import {
AlertCircle,
PanelLeft,
PanelLeftClose,
Star,
FileText,
Plus,
} from 'lucide-react'
import { trpc } from '~/integrations/trpc/client'
import { FileTree, MarkdownViewer } from '~/components/workspace'
import {
FileTree,
FileEditor,
MobileBottomToolbar,
MobileFileDrawer,
MobilePathSheet,
ConfirmationDialog,
FileContextMenu,
TrashIcon,
CopyIcon,
EditIcon,
NewFileDialog,
} from '~/components/workspace'
import { NavTabs } from '~/components/navigation'
import { CrabIdleAnimation } from '~/components/ani'
import { useIsMobile } from '~/hooks/useIsMobile'
import type { DirectoryEntry } from '~/lib/workspace-fs'
// Get parent directory path using path separator logic
@@ -82,10 +99,18 @@ function WorkspacePage() {
// Sidebar collapse state
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
// Starred files state
const [starredPaths, setStarredPaths] = useState<Set<string>>(new Set())
// Mobile state
const isMobile = useIsMobile()
const [fileDrawerOpen, setFileDrawerOpen] = useState(false)
const [pathSheetOpen, setPathSheetOpen] = useState(false)
// Root entries for FileTree
const rootEntries = workspacePath && pathValid ? (pathCache.get(workspacePath) || []) : []
// Load saved path or default on mount
// Load saved path and starred files on mount
useEffect(() => {
const savedPath = localStorage.getItem('crabcrawl:workspacePath')
if (savedPath) {
@@ -95,6 +120,17 @@ function WorkspacePage() {
} else {
loadDefaultPath()
}
// Load starred files
const savedStarred = localStorage.getItem('crabcrawl:starredFiles')
if (savedStarred) {
try {
const parsed = JSON.parse(savedStarred)
setStarredPaths(new Set(parsed))
} catch {
// ignore invalid JSON
}
}
}, [])
// Load entries when workspace path changes and is valid
@@ -312,7 +348,189 @@ function WorkspacePage() {
}
}
// Handle starring/unstarring files
const handleStar = useCallback((filePath: string) => {
setStarredPaths((prev) => {
const next = new Set(prev)
if (next.has(filePath)) {
next.delete(filePath)
} else {
next.add(filePath)
}
// Persist to localStorage
localStorage.setItem('crabcrawl:starredFiles', JSON.stringify([...next]))
return next
})
}, [])
// Delete confirmation dialog state
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
// New file dialog state
const [newFileDialogOpen, setNewFileDialogOpen] = useState(false)
const [newFileFolderPath, setNewFileFolderPath] = useState<string | null>(null)
// Context menu state
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [contextMenuFilePath, setContextMenuFilePath] = useState<string | null>(null)
// Clipboard feedback state
const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle')
// Handle save file with confirmation callback
const handleSave = useCallback(
async (content: string, callback: (success: boolean) => void) => {
if (!selectedPath) {
callback(false)
return
}
try {
await trpc.workspace.writeFile.mutate({
workspaceRoot: workspacePath,
path: selectedPath,
content,
})
// Reload the file to get updated metadata
await loadFile(selectedPath)
// Refresh the directory to update metadata
await handleRefresh()
callback(true)
} catch (error) {
console.error('Failed to save file:', error)
callback(false)
}
},
[selectedPath, workspacePath, loadFile, handleRefresh]
)
// Handle delete file
const handleDeleteFile = useCallback(async () => {
if (!fileToDelete) return
try {
await trpc.workspace.deleteFile.mutate({
workspaceRoot: workspacePath,
path: fileToDelete,
})
// If deleted file was selected, clear selection
if (selectedPath === fileToDelete) {
setSelectedPath(null)
setSelectedFileContent('')
setSelectedFileName('')
setSelectedFileSize(undefined)
setSelectedFileModified(undefined)
}
// Clear cache and refresh
setPathCache(new Map())
await handleRefresh()
} catch (error) {
console.error('Failed to delete file:', error)
} finally {
setFileToDelete(null)
setDeleteConfirmOpen(false)
}
}, [fileToDelete, workspacePath, selectedPath, handleRefresh])
// Handle create file (uses newFileFolderPath if set)
const handleCreateFile = useCallback(
async (fileName: string, content: string) => {
try {
// If creating in a subfolder, prepend the relative path
let finalFileName = fileName
if (newFileFolderPath && newFileFolderPath !== workspacePath) {
const relativePath = newFileFolderPath.replace(workspacePath + '/', '')
finalFileName = `${relativePath}/${fileName}`
}
const result = await trpc.workspace.createFile.mutate({
workspaceRoot: workspacePath,
fileName: finalFileName,
content,
})
if (result.success && result.filePath) {
// Clear cache and refresh
setPathCache(new Map())
await handleRefresh()
// Select the new file
setSelectedPath(result.filePath)
await loadFile(result.filePath)
} else if (result.error) {
throw new Error(result.error)
}
} catch (error) {
console.error('Failed to create file:', error)
throw error
} finally {
setNewFileFolderPath(null)
}
},
[workspacePath, newFileFolderPath, loadFile, handleRefresh]
)
// Handle context menu
const handleContextMenu = useCallback(
(e: React.MouseEvent, filePath: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenuFilePath(filePath)
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuOpen(true)
},
[]
)
// Handle create file in folder (from + button in tree)
const handleCreateFileInFolder = useCallback((folderPath: string) => {
setNewFileFolderPath(folderPath)
setNewFileDialogOpen(true)
}, [])
// Context menu items
const contextMenuItems = useMemo(() => [
{
icon: <EditIcon size={14} />,
label: 'Edit',
onClick: () => {
if (contextMenuFilePath) {
handleSelect(contextMenuFilePath, 'file')
}
},
},
{
icon: <CopyIcon size={14} />,
label: copyFeedback === 'copied' ? 'Copied!' : copyFeedback === 'failed' ? 'Failed' : 'Copy Path',
onClick: async () => {
if (contextMenuFilePath) {
try {
await navigator.clipboard.writeText(contextMenuFilePath)
setCopyFeedback('copied')
setTimeout(() => setCopyFeedback('idle'), 2000)
} catch {
setCopyFeedback('failed')
setTimeout(() => setCopyFeedback('idle'), 2000)
}
}
},
},
{
icon: <TrashIcon size={14} />,
label: 'Delete',
danger: true,
onClick: () => {
if (contextMenuFilePath) {
setFileToDelete(contextMenuFilePath)
setDeleteConfirmOpen(true)
}
},
},
], [contextMenuFilePath, handleSelect, copyFeedback])
return (
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
@@ -330,48 +548,33 @@ function WorkspacePage() {
</Link>
{/* Navigation tabs */}
<div className="flex items-center gap-1">
{/* Monitor tab - inactive */}
<Link
to="/monitor"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
>
<span className="font-arcade text-xs text-gray-500 tracking-wider">
MONITOR
</span>
</Link>
{/* Workspace tab - active */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
<div className="crab-icon-glow">
<CrabIdleAnimation className="w-5 h-5" />
</div>
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
WORKSPACE
</span>
</div>
</div>
<NavTabs />
</div>
<div className="relative flex items-center gap-3 flex-1 max-w-2xl mx-4">
{/* Path input */}
<div className="flex-1 flex items-center gap-2">
<FolderOpen size={16} className="text-shell-500 flex-shrink-0" />
{/* Path input - desktop only */}
<div className="hidden sm:flex relative items-center gap-2 flex-1 max-w-2xl mx-4">
<div className="flex-1 relative">
<FolderOpen size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500 pointer-events-none" />
<input
type="text"
value={workspacePathInput}
onChange={(e) => setWorkspacePathInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter workspace path..."
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20"
className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20"
/>
<button
onClick={validateAndSetPath}
className="px-3 py-1.5 bg-crab-600 hover:bg-crab-500 text-white text-sm font-display rounded-lg transition-colors"
>
Open
</button>
</div>
<button
onClick={validateAndSetPath}
disabled={pathValid && workspacePathInput === workspacePath}
className={`px-3 py-1.5 text-sm font-display rounded-lg transition-colors shrink-0 ${
pathValid && workspacePathInput === workspacePath
? 'bg-shell-800 text-shell-500 cursor-default'
: 'bg-crab-600 hover:bg-crab-500 text-white'
}`}
>
Open
</button>
{pathError && (
<div className="absolute top-full left-0 right-0 mt-2 px-3 py-2 bg-crab-900/90 border border-crab-700 rounded-lg flex items-center gap-2 z-50">
@@ -381,8 +584,8 @@ function WorkspacePage() {
)}
</div>
<div className="relative flex items-center gap-3">
{/* Refresh button */}
{/* Refresh button - desktop only */}
<div className="hidden sm:flex relative items-center gap-3">
<button
onClick={handleRefresh}
disabled={!pathValid || loading}
@@ -399,93 +602,251 @@ function WorkspacePage() {
{/* Main content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<AnimatePresence initial={false}>
{!sidebarCollapsed && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 320, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
>
{/* Sidebar header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-shell-800">
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
Files
</span>
<div className="flex items-center gap-2">
{loading && (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<RefreshCw size={14} className="text-shell-500" />
</motion.div>
)}
<button
onClick={() => setSidebarCollapsed(true)}
className="p-1 hover:bg-shell-800 rounded transition-colors"
title="Hide sidebar"
>
<PanelLeftClose size={14} className="text-shell-500 hover:text-crab-400" />
</button>
</div>
</div>
{/* File tree */}
<div className="flex-1 overflow-auto py-2">
{pathValid ? (
<FileTree
entries={rootEntries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
/>
{/* Sidebar - desktop only */}
{!isMobile && (
<motion.div
initial={false}
animate={{ width: sidebarCollapsed ? 56 : 320 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
>
{/* Sidebar header */}
<div className={`flex items-center justify-between px-3 py-3 border-b border-shell-800 ${sidebarCollapsed ? 'justify-center' : ''}`}>
{!sidebarCollapsed && (
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
Files
</span>
)}
<div className={`flex items-center gap-1 ${sidebarCollapsed ? 'mx-auto flex-col' : ''}`}>
{loading && !sidebarCollapsed && (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<RefreshCw size={14} className="text-shell-500" />
</motion.div>
)}
{!sidebarCollapsed && (
<button
onClick={() => setNewFileDialogOpen(true)}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
title="New File"
>
<Plus size={16} className="text-shell-500 hover:text-neon-mint" />
</button>
)}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
>
{sidebarCollapsed ? (
<PanelLeft size={16} className="text-gray-400 hover:text-crab-400" />
) : (
<div className="p-4 text-center">
<p className="font-console text-xs text-shell-500">
Enter a workspace path to browse files
</p>
</div>
<PanelLeftClose size={16} className="text-shell-500 hover:text-crab-400" />
)}
</div>
</button>
</div>
</div>
{/* Sidebar footer */}
{pathValid && (
<div className="px-4 py-2 border-t border-shell-800">
<p className="font-console text-[10px] text-shell-600 truncate">
{workspacePath}
{/* Starred files section */}
{starredPaths.size > 0 && (
<>
{sidebarCollapsed ? (
// Collapsed: stacked file icons
<div className="flex flex-col items-center gap-1 py-2 border-b border-shell-800">
{[...starredPaths].slice(0, 5).map((filePath) => {
const fileName = filePath.split('/').pop() || filePath
const isSelected = selectedPath === filePath
return (
<button
key={filePath}
onClick={() => handleSelect(filePath, 'file')}
className={`relative p-1.5 rounded transition-colors ${
isSelected ? 'bg-crab-500/20' : 'hover:bg-shell-800'
}`}
title={fileName}
>
<FileText
size={16}
className={isSelected ? 'text-crab-400' : 'text-shell-500'}
/>
<Star
size={8}
fill="currentColor"
className="absolute -top-0.5 -right-0.5 text-yellow-400"
/>
</button>
)
})}
{starredPaths.size > 5 && (
<span className="text-[11px] text-shell-500">+{starredPaths.size - 5}</span>
)}
</div>
) : (
// Expanded: starred files list
<div className="border-b border-shell-800 py-2">
{[...starredPaths].map((filePath) => {
const fileName = filePath.split('/').pop() || filePath
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
const isSelected = selectedPath === filePath
return (
<div
key={filePath}
className={`group flex items-center gap-2 px-4 py-1.5 cursor-pointer transition-colors ${
isSelected
? 'bg-crab-500/20 text-crab-400'
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
}`}
onClick={() => handleSelect(filePath, 'file')}
>
<FileText
size={14}
className={`shrink-0 ${
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
}`}
/>
<span className="font-console text-sm truncate flex-1">
{fileName}
</span>
<button
onClick={(e) => {
e.stopPropagation()
handleStar(filePath)
}}
className="text-yellow-400 hover:text-yellow-300 shrink-0"
title="Unstar file"
>
<Star size={14} fill="currentColor" />
</button>
</div>
)
})}
</div>
)}
</>
)}
{/* File tree */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
{pathValid ? (
<FileTree
entries={rootEntries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
onContextMenu={handleContextMenu}
onCreateFile={handleCreateFileInFolder}
/>
) : (
<div className="p-4 text-center">
<p className="font-console text-xs text-shell-500">
Enter a workspace path to browse files
</p>
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
{/* Main content area */}
<div className="flex-1 relative bg-shell-950">
{/* Floating sidebar toggle when collapsed */}
{sidebarCollapsed && (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={() => setSidebarCollapsed(false)}
className="absolute left-4 top-4 z-10 p-2 bg-shell-800/80 hover:bg-shell-700 rounded-lg border border-shell-700 transition-all"
title="Show sidebar"
>
<PanelLeft size={18} className="text-gray-400 hover:text-crab-400" />
</motion.button>
{/* Sidebar footer */}
{pathValid && !sidebarCollapsed && (
<div className="px-4 py-2 border-t border-shell-800">
<p className="font-console text-[11px] text-shell-600 truncate">
{workspacePath}
</p>
</div>
)}
<MarkdownViewer
</motion.div>
)}
{/* Main content area */}
<div className={`flex-1 relative bg-shell-950 ${isMobile ? 'pb-20' : ''}`}>
<FileEditor
content={selectedFileContent}
fileName={selectedFileName}
filePath={selectedPath ?? undefined}
fileSize={selectedFileSize}
fileModified={selectedFileModified}
error={fileError}
isStarred={selectedPath ? starredPaths.has(selectedPath) : false}
onStar={handleStar}
onSave={handleSave}
/>
</div>
</div>
{/* Mobile components */}
{isMobile && (
<>
<MobileBottomToolbar
onOpenDrawer={() => setFileDrawerOpen(true)}
onOpenPathSheet={() => setPathSheetOpen(true)}
onRefresh={handleRefresh}
loading={loading}
pathValid={pathValid}
currentPath={workspacePathInput}
/>
<MobileFileDrawer
open={fileDrawerOpen}
onClose={() => setFileDrawerOpen(false)}
entries={rootEntries}
selectedPath={selectedPath}
starredPaths={starredPaths}
workspacePath={workspacePath}
pathValid={pathValid}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
onStar={handleStar}
/>
<MobilePathSheet
open={pathSheetOpen}
onClose={() => setPathSheetOpen(false)}
initialPath={workspacePathInput}
validatedPath={workspacePath}
pathValid={pathValid}
pathError={pathError}
onValidate={async (path) => {
await validatePathAndSet(path)
return pathValid
}}
/>
</>
)}
{/* Delete confirmation dialog */}
<ConfirmationDialog
open={deleteConfirmOpen}
title="Delete File"
message={`Are you sure you want to delete "${fileToDelete?.split('/').pop()}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteFile}
onCancel={() => {
setDeleteConfirmOpen(false)
setFileToDelete(null)
}}
variant="danger"
/>
{/* New file dialog */}
<NewFileDialog
open={newFileDialogOpen}
onClose={() => {
setNewFileDialogOpen(false)
setNewFileFolderPath(null)
}}
onCreate={handleCreateFile}
folderPath={newFileFolderPath ?? undefined}
/>
{/* Context menu */}
<FileContextMenu
open={contextMenuOpen}
position={contextMenuPosition}
items={contextMenuItems}
onClose={() => setContextMenuOpen(false)}
/>
</div>
)
}
+5
View File
@@ -433,3 +433,8 @@ code, pre {
@apply focus:outline-none focus:border-crab-500 focus:ring-2 focus:ring-crab-500/20;
@apply transition-all duration-150;
}
/* Safe area padding for iOS */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0);
}