31 Commits

Author SHA1 Message Date
viper151
289c2334e0 Merge pull request #224 from Henry-Jessie/feat/math-rendering
feat: support math rendering with KaTeX
2025-11-05 23:03:29 +01:00
viper151
de8c4d1845 Merge branch 'main' into feat/math-rendering 2025-11-05 23:02:01 +01:00
simos
23de8c7863 feature: is_platform changes 2025-11-05 00:25:47 +01:00
viper151
041c72b160 Merge branch 'main' into feat/math-rendering 2025-11-04 10:42:56 +01:00
viper151
519b5e5209 Merge pull request #227 from siteboon/feature/new-project-creation
feat(projects): add project creation wizard with enhanced UX
2025-11-04 10:37:30 +01:00
simos
7ab14750de Merge remote-tracking branch 'origin/feature/new-project-creation' into feature/new-project-creation 2025-11-04 09:32:42 +00:00
simos
255aed0b01 feat(projects): add workspace path security validation and align github credentials implementation across components 2025-11-04 09:29:21 +00:00
viper151
b416e542c7 Apply suggestion from @coderabbitai[bot]
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-04 09:39:37 +01:00
viper151
43cbbb10d9 Merge branch 'main' into feature/new-project-creation 2025-11-04 09:31:09 +01:00
viper151
003b64f8f0 Merge branch 'main' into feat/math-rendering 2025-11-04 09:29:07 +01:00
viper151
401223dcd5 Merge pull request #225 from atelierai/fix_image_viewer
fix: fix image viewer return 401 error
2025-11-04 09:28:02 +01:00
viper151
499e33d910 Merge pull request #226 from LeoZheng1738/fix_siderbar
fix(Sidebar): The undefined setShowSuggestions method has been removed.
2025-11-04 09:27:13 +01:00
simos
0181883c8a feat(projects): add project creation wizard with enhanced UX
Add new project creation wizard component with improved user experience
and better input field interactions. Integrate projects API routes on
the backend to support CRUD operations for project management.

Changes include:
- Add ProjectCreationWizard component with step-by-step project setup
- Improve input hint visibility to hide when user starts typing
- Refactor project creation state management in Sidebar component
- Add ReactDOM import for portal-based wizard rendering

The wizard provides a more intuitive onboarding experience for users
creating new projects, while the enhanced input hints reduce visual
clutter during active typing.
2025-11-04 08:26:31 +00:00
Henry-Jessie
a100aa598c fix: protect LaTeX formulas when unescaping JSONL escape sequences 2025-11-04 16:26:17 +08:00
LeoZheng1738
c875907f55 fix(Sidebar): The undefined setShowSuggestions method has been removed. 2025-11-04 11:33:58 +08:00
Sayo
b2c16002e4 fix: fix image viewer return 401 error 2025-11-03 03:17:54 +08:00
simos
c7dbab086b fixing slash commands button 2025-11-02 09:36:31 +00:00
simos
b31f7afdf5 Release 1.11.0 2025-11-02 10:27:25 +01:00
Henry-Jessie
06d17eb22e feat: support math rendering with KaTeX 2025-11-02 16:36:23 +08:00
simos
57739a659f package-lock.json 2025-11-02 08:01:11 +00:00
viper151
a5813e66d9 Merge pull request #223 from siteboon/feature/cli-commands
Feature/cli commands
2025-11-02 08:59:39 +01:00
viper151
18ea4a19dd Merge branch 'main' into feature/cli-commands 2025-11-02 08:59:28 +01:00
simos
1c95c598eb docs: update installation and CLI documentation
Update .env.example with comprehensive CLI command documentation and
clearer DATABASE_PATH configuration comments. Enhance README.md with
restructured installation guide featuring new cloudcli commands,
detailed PM2 background service setup instructions, and improved
organization of global installation benefits and restart procedures.

Add CLI command reference showing cloudcli start, status, help, and
version commands. Expand PM2 section with separate subsections for
installation, service startup, and auto-start configuration.
2025-11-02 07:53:22 +00:00
simos
72e97c4fbc Release 1.10.5 2025-11-01 11:05:16 +01:00
viper151
b5d1fed354 feat(chat): add CLAUDE.md support and fix scroll behavior (#222) 2025-11-01 07:01:25 +01:00
simos
d1733f34e0 feat(chat): add CLAUDE.md support and fix scroll behavior
Add system prompt configuration to enable CLAUDE.md loading from
project, user config, and local directories. This allows Claude Code
to use custom instructions defined in CLAUDE.md files.

Fix scroll position management during message streaming to prevent
conflicting with user's manual scroll actions. Remove automatic
scroll state reset in scrollToBottom function and let scroll event
handler manage the state naturally.

Also remove debug logging for session ID capture.
2025-11-01 05:55:11 +00:00
simos
fefcc0f338 feat(editor): Move code editor preferences to settings and add option to expand editor
Add global settings integration and persistent user preferences for
the code editor. Settings are now stored in localStorage and persist
across sessions.

Changes:
- Add theme, word wrap, minimap, line numbers, and font size settings
- Load editor preferences from localStorage on initialization
- Expose global openSettings function for cross-component access
- Add settingsInitialTab state to control which settings tab opens
- Pass initialTab prop to Settings component for navigation

This improves UX by remembering user preferences and allows other
components to open settings to specific tabs programmatically.
2025-10-31 12:11:47 +00:00
simos
36f8f50d63 feat(editor): add sidebar mode to CodeEditor component
Add an optional  prop to the CodeEditor component to support
rendering in both modal and sidebar layouts. When enabled, the editor
adapts its container styling and removes the fixed overlay positioning,
allowing it to be embedded inline. This includes conditional rendering
of the loading state and main container to properly display within a
sidebar context while maintaining the existing modal behavior as the
default.
2025-10-31 09:47:30 +00:00
simos
4e14222487 feat(ui): add collapsible sidebar functionality
Implement a collapsible sidebar feature for the desktop view that allows
users to toggle between expanded and collapsed states. The sidebar state
is persisted using localStorage to maintain user preference across
sessions.

Changes include:
- Add sidebarVisible state with localStorage persistence
- Import Sparkles and SettingsIcon from lucide-react
- Implement smooth transition animation (300ms) for sidebar collapse
- Add collapsed sidebar view with icon-only navigation buttons
- Pass onToggleSidebar prop to Sidebar component
- Adjust sidebar width dynamically (80 -> 14 when collapsed)

This improves the user experience by providing more screen real estate
for the main content area when needed, while keeping quick access to
essential navigation through the collapsed icon view.
2025-10-31 09:36:14 +00:00
simos
e2ba000e86 Release 1.10.4 2025-10-31 10:17:44 +01:00
simos
64e2909f0f feat(updates): add system update endpoint and UI
Add automatic update functionality to allow users to update the
application directly from the UI without manual git commands.

Changes:
- Add POST /api/system/update endpoint that runs git pull and npm
  install
- Enhance useVersionCheck hook to fetch release information including
  changelog
- Update VersionUpgradeModal to display changelog and handle one-click
  updates
- Add update progress tracking with output display and error handling
- Bump version to 1.10.4

The update endpoint executes git checkout main, git pull, and npm
install, providing real-time output to the user. After successful
update, users are prompted to restart the server.
2025-10-31 09:15:50 +00:00
27 changed files with 2889 additions and 777 deletions

View File

@@ -1,5 +1,15 @@
# Claude Code UI Environment Configuration
# Only includes variables that are actually used in the code
#
# TIP: Run 'cloudcli status' to see where this file should be located
# and to view your current configuration.
#
# Available CLI commands:
# claude-code-ui - Start the server (default)
# cloudcli start - Start the server
# cloudcli status - Show configuration and data locations
# cloudcli help - Show help information
# cloudcli version - Show version information
# =============================================================================
# SERVER CONFIGURATION
@@ -19,11 +29,13 @@ VITE_PORT=5173
# =============================================================================
# Path to the authentication database file
# This should be set to a persistent volume path when running in containers
# Default: server/database/auth.db (relative to project root)
# Example for Docker: /data/auth.db
# DATABASE_PATH=/data/auth.db
# This is where user credentials, API keys, and tokens are stored.
#
# To use a custom location:
# DATABASE_PATH=/path/to/your/custom/auth.db
#
# Claude Code context window size (maximum tokens per session)
# Note: VITE_ prefix makes it available to frontend
VITE_CONTEXT_WINDOW=160000
CONTEXT_WINDOW=160000
# VITE_IS_PLATFORM=false

View File

@@ -69,8 +69,7 @@ npx @siteboon/claude-code-ui
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
**To restart**: Simply run the same `npx` command again after stopping the server (Ctrl+C or Cmd+C).
**To restart**: Simply run the same `npx` command again after stopping the server
### Global Installation (For Regular Use)
For frequent use, install globally once:
@@ -85,32 +84,71 @@ Then start with a simple command:
claude-code-ui
```
**Benefits**:
- Faster startup (no download/cache check)
- Simple command to remember
- Same experience every time
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
### Run as Background Service (Optional)
### CLI Commands
To keep the server running in the background, use PM2:
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
```bash
# Install PM2 globally (one-time)
npm install -g pm2
# Start the server (default command)
claude-code-ui
cloudcli start
# Start the server
pm2 start claude-code-ui --name "claude-ui"
# Show configuration and data locations
cloudcli status
# Manage the service
pm2 list # View status
pm2 restart claude-ui # Restart
pm2 stop claude-ui # Stop
pm2 logs claude-ui # View logs
pm2 startup # Auto-start on system boot
# Show help information
cloudcli help
# Show version
cloudcli version
```
**The `cloudcli status` command shows you:**
- Installation directory location
- Database location (where credentials are stored)
- Current configuration (PORT, DATABASE_PATH, etc.)
- Claude projects folder location
- Configuration file location
```
### Run as Background Service (Recommended for Production)
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
#### Install PM2
```bash
npm install -g pm2
```
#### Start as Background Service
```bash
# Start the server in background
pm2 start claude-code-ui --name "claude-code-ui"
# Or using the shorter alias
pm2 start cloudcli --name "claude-code-ui"
```
#### Auto-Start on System Boot
To make Claude Code UI start automatically when your system boots:
```bash
# Generate startup script for your platform
pm2 startup
# Save current process list
pm2 save
```
### Local Development Installation
1. **Clone the repository:**

318
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.10.3",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.10.3",
"version": "1.11.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -37,6 +37,7 @@
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
@@ -47,14 +48,17 @@
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2"
},
"bin": {
"claude-code-ui": "server/index.js"
"claude-code-ui": "server/cli.js",
"cloudcli": "server/cli.js"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -2859,6 +2863,12 @@
"@types/unist": "*"
}
},
"node_modules/@types/katex": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -4609,6 +4619,18 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -5539,6 +5561,101 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-dom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
"license": "ISC",
"dependencies": {
"@types/hast": "^3.0.0",
"hastscript": "^9.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.1.0",
"hast-util-from-parse5": "^8.0.0",
"parse5": "^7.0.0",
"vfile": "^6.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html-isomorphic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-dom": "^5.0.0",
"hast-util-from-html": "^2.0.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -5566,6 +5683,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unist-util-find-after": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -5579,6 +5712,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6219,6 +6369,31 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/katex": {
"version": "0.16.25",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz",
"integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -6583,6 +6758,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-math": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
"integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"longest-streak": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.1.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -6945,6 +7139,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-math": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
"license": "MIT",
"dependencies": {
"@types/katex": "^0.16.0",
"devlop": "^1.0.0",
"katex": "^0.16.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -8282,6 +8495,18 @@
"node": ">=14.13.0"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -8945,6 +9170,25 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
"integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/katex": "^0.16.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.0",
"katex": "^0.16.0",
"unist-util-visit-parents": "^6.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/release-it": {
"version": "19.0.5",
"resolved": "https://registry.npmjs.org/release-it/-/release-it-19.0.5.tgz",
@@ -9024,6 +9268,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-math": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
"integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-math": "^3.0.0",
"micromark-extension-math": "^3.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -11097,6 +11357,20 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/unist-util-find-after": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -11123,6 +11397,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-remove-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -11259,6 +11547,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz",
@@ -11385,6 +11687,16 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -1,11 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.10.3",
"version": "1.11.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
"bin": {
"claude-code-ui": "server/index.js"
"claude-code-ui": "server/cli.js",
"cloudcli": "server/cli.js"
},
"files": [
"server/",
@@ -67,6 +68,7 @@
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
@@ -77,7 +79,9 @@
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",

View File

@@ -79,6 +79,16 @@ function mapCliOptionsToSDK(options = {}) {
// Map model (default to sonnet)
sdkOptions.model = options.model || 'sonnet';
// Map system prompt configuration
sdkOptions.systemPrompt = {
type: 'preset',
preset: 'claude_code' // Required to use CLAUDE.md
};
// Map setting sources for CLAUDE.md loading
// This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
sdkOptions.settingSources = ['project', 'user', 'local'];
// Map resume session
if (sessionId) {
sdkOptions.resume = sessionId;
@@ -374,7 +384,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
for await (const message of queryInstance) {
// Capture session ID from first message
if (message.session_id && !capturedSessionId) {
console.log('📝 Captured session ID:', message.session_id);
capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);

225
server/cli.js Executable file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* Claude Code UI CLI
*
* Provides command-line utilities for managing Claude Code UI
*
* Commands:
* (no args) - Start the server (default)
* start - Start the server
* status - Show configuration and data locations
* help - Show help information
* version - Show version information
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
// Foreground colors
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
white: '\x1b[37m',
gray: '\x1b[90m',
};
// Helper to colorize text
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
error: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
// Load package.json for version info
const packageJsonPath = path.join(__dirname, '../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Load environment variables from .env file if it exists
function loadEnvFile() {
try {
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
if (key && valueParts.length > 0 && !process.env[key]) {
process.env[key] = valueParts.join('=').trim();
}
}
});
} catch (e) {
// .env file is optional
}
}
// Get the database path (same logic as db.js)
function getDatabasePath() {
loadEnvFile();
return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db');
}
// Get the installation directory
function getInstallDir() {
return path.join(__dirname, '..');
}
// Show status command
function showStatus() {
console.log(`\n${c.bright('Claude Code UI - Status')}\n`);
console.log(c.dim('═'.repeat(60)));
// Version info
console.log(`\n${c.info('[INFO]')} Version: ${c.bright(packageJson.version)}`);
// Installation location
const installDir = getInstallDir();
console.log(`\n${c.info('[INFO]')} Installation Directory:`);
console.log(` ${c.dim(installDir)}`);
// Database location
const dbPath = getDatabasePath();
const dbExists = fs.existsSync(dbPath);
console.log(`\n${c.info('[INFO]')} Database Location:`);
console.log(` ${c.dim(dbPath)}`);
console.log(` Status: ${dbExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not created yet (will be created on first run)')}`);
if (dbExists) {
const stats = fs.statSync(dbPath);
console.log(` Size: ${c.dim((stats.size / 1024).toFixed(2) + ' KB')}`);
console.log(` Modified: ${c.dim(stats.mtime.toLocaleString())}`);
}
// Environment variables
console.log(`\n${c.info('[INFO]')} Configuration:`);
console.log(` PORT: ${c.bright(process.env.PORT || '3001')} ${c.dim(process.env.PORT ? '' : '(default)')}`);
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
// Claude projects folder
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
const projectsExists = fs.existsSync(claudeProjectsPath);
console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`);
console.log(` ${c.dim(claudeProjectsPath)}`);
console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);
// Config file location
const envFilePath = path.join(__dirname, '../.env');
const envExists = fs.existsSync(envFilePath);
console.log(`\n${c.info('[INFO]')} Configuration File:`);
console.log(` ${c.dim(envFilePath)}`);
console.log(` Status: ${envExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found (using defaults)')}`);
console.log('\n' + c.dim('═'.repeat(60)));
console.log(`\n${c.tip('[TIP]')} Hints:`);
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`);
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
}
// Show help
function showHelp() {
console.log(`
╔═══════════════════════════════════════════════════════════════╗
║ Claude Code UI - Command Line Tool ║
╚═══════════════════════════════════════════════════════════════╝
Usage:
claude-code-ui [command]
cloudcli [command]
Commands:
start Start the Claude Code UI server (default)
status Show configuration and data locations
help Show this help information
version Show version information
Examples:
$ claude-code-ui # Start the server
$ cloudcli status # Show configuration
$ cloudcli help # Show help
Environment Variables:
PORT Set server port (default: 3001)
DATABASE_PATH Set custom database location
CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000)
Configuration:
Create a .env file in the installation directory to set
persistent environment variables. Use 'cloudcli status' to
see the installation directory path.
Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
Report Issues:
${packageJson.bugs?.url || 'https://github.com/siteboon/claudecodeui/issues'}
`);
}
// Show version
function showVersion() {
console.log(`${packageJson.version}`);
}
// Start the server
async function startServer() {
// Import and run the server
await import('./index.js');
}
// Main CLI handler
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'start';
switch (command) {
case 'start':
await startServer();
break;
case 'status':
case 'info':
showStatus();
break;
case 'help':
case '-h':
case '--help':
showHelp();
break;
case 'version':
case '-v':
case '--version':
showVersion();
break;
default:
console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n');
process.exit(1);
}
}
// Run the CLI
main().catch(error => {
console.error('\n❌ Error:', error.message);
process.exit(1);
});

View File

@@ -8,6 +8,20 @@ import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
// Use DATABASE_PATH environment variable if set, otherwise use default location
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
@@ -28,7 +42,18 @@ if (process.env.DATABASE_PATH) {
// Create database connection
const db = new Database(DB_PATH);
console.log(`Connected to SQLite database at: ${DB_PATH}`);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
console.log(c.dim('═'.repeat(60)));
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
if (process.env.DATABASE_PATH) {
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
}
console.log(c.dim('═'.repeat(60)));
console.log('');
// Initialize database with schema
const initializeDatabase = async () => {
@@ -92,6 +117,15 @@ const userDb = {
} catch (err) {
throw err;
}
},
getFirstUser: () => {
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
return row;
} catch (err) {
throw err;
}
}
};

View File

@@ -8,6 +8,26 @@ import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
try {
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
@@ -49,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -116,7 +137,7 @@ async function setupProjectsWatcher() {
});
} catch (error) {
console.error(' Error handling project changes:', error);
console.error('[ERROR] Error handling project changes:', error);
}
}, 300); // 300ms debounce (slightly faster than before)
};
@@ -129,13 +150,13 @@ async function setupProjectsWatcher() {
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
.on('error', (error) => {
console.error(' Chokidar watcher error:', error);
console.error('[ERROR] Chokidar watcher error:', error);
})
.on('ready', () => {
});
} catch (error) {
console.error(' Failed to setup projects watcher:', error);
console.error('[ERROR] Failed to setup projects watcher:', error);
}
}
@@ -149,6 +170,19 @@ const wss = new WebSocketServer({
verifyClient: (info) => {
console.log('WebSocket connection attempt to:', info.req.url);
// Platform mode: always allow connection
if (process.env.VITE_IS_PLATFORM === 'true') {
const user = authenticateWebSocket(null); // Will return first user
if (!user) {
console.log('[WARN] Platform mode: No user found in database');
return false;
}
info.req.user = user;
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
return true;
}
// Normal mode: verify token
// Extract token from query parameters or headers
const url = new URL(info.req.url, 'http://localhost');
const token = url.searchParams.get('token') ||
@@ -157,13 +191,13 @@ const wss = new WebSocketServer({
// Verify token
const user = authenticateWebSocket(token);
if (!user) {
console.log(' WebSocket authentication failed');
console.log('[WARN] WebSocket authentication failed');
return false;
}
// Store user info in the request for later use
info.req.user = user;
console.log(' WebSocket authenticated for user:', user.username);
console.log('[OK] WebSocket authenticated for user:', user.username);
return true;
}
});
@@ -175,12 +209,23 @@ app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// Public health check endpoint (no authentication required)
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString()
});
});
// Optional API key validation (if configured)
app.use('/api', validateApiKey);
// Authentication routes (public)
app.use('/api/auth', authRoutes);
// Projects API Routes (protected)
app.use('/api/projects', authenticateToken, projectsRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
@@ -237,6 +282,71 @@ app.get('/api/config', authenticateToken, (req, res) => {
});
});
// System update endpoint
app.post('/api/system/update', authenticateToken, async (req, res) => {
try {
// Get the project root directory (parent of server directory)
const projectRoot = path.join(__dirname, '..');
console.log('Starting system update from directory:', projectRoot);
// Run the update command
const updateCommand = 'git checkout main && git pull && npm install';
const child = spawn('sh', ['-c', updateCommand], {
cwd: projectRoot,
env: process.env
});
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
const text = data.toString();
output += text;
console.log('Update output:', text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
console.error('Update error:', text);
});
child.on('close', (code) => {
if (code === 0) {
res.json({
success: true,
output: output || 'Update completed successfully',
message: 'Update completed. Please restart the server to apply changes.'
});
} else {
res.status(500).json({
success: false,
error: 'Update command failed',
output: output,
errorOutput: errorOutput
});
}
});
child.on('error', (error) => {
console.error('Update process error:', error);
res.status(500).json({
success: false,
error: error.message
});
});
} catch (error) {
console.error('System update error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
@@ -397,7 +507,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
const { projectName } = req.params;
const { filePath } = req.query;
console.log('📄 File read request:', projectName, filePath);
console.log('[DEBUG] File read request:', projectName, filePath);
// Security: ensure the requested path is inside the project root
if (!filePath) {
@@ -438,7 +548,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
const { projectName } = req.params;
const { path: filePath } = req.query;
console.log('🖼️ Binary file serve request:', projectName, filePath);
console.log('[DEBUG] Binary file serve request:', projectName, filePath);
// Security: ensure the requested path is inside the project root
if (!filePath) {
@@ -492,7 +602,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
const { projectName } = req.params;
const { filePath, content } = req.body;
console.log('💾 File save request:', projectName, filePath);
console.log('[DEBUG] File save request:', projectName, filePath);
// Security: ensure the requested path is inside the project root
if (!filePath) {
@@ -563,7 +673,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
res.json(files);
} catch (error) {
console.error(' File tree error:', error.message);
console.error('[ERROR] File tree error:', error.message);
res.status(500).json({ error: error.message });
}
});
@@ -571,7 +681,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
// WebSocket connection handler that routes based on URL path
wss.on('connection', (ws, request) => {
const url = request.url;
console.log('🔗 Client connected to:', url);
console.log('[INFO] Client connected to:', url);
// Parse URL to get pathname without query parameters
const urlObj = new URL(url, 'http://localhost');
@@ -582,14 +692,14 @@ wss.on('connection', (ws, request) => {
} else if (pathname === '/ws') {
handleChatConnection(ws);
} else {
console.log(' Unknown WebSocket path:', pathname);
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
}
});
// Handle chat WebSocket connections
function handleChatConnection(ws) {
console.log('💬 Chat WebSocket connected');
console.log('[INFO] Chat WebSocket connected');
// Add to connected clients for project updates
connectedClients.add(ws);
@@ -599,28 +709,28 @@ function handleChatConnection(ws) {
const data = JSON.parse(message);
if (data.type === 'claude-command') {
console.log('💬 User message:', data.command || '[Continue/Resume]');
console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
// Use Claude Agents SDK
await queryClaudeSDK(data.command, data.options, ws);
} else if (data.type === 'cursor-command') {
console.log('🖱️ Cursor message:', data.command || '[Continue/Resume]');
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await spawnCursor(data.command, data.options, ws);
} else if (data.type === 'cursor-resume') {
// Backward compatibility: treat as cursor-command with resume and no prompt
console.log('🖱️ Cursor resume session (compat):', data.sessionId);
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
await spawnCursor('', {
sessionId: data.sessionId,
resume: true,
cwd: data.options?.cwd
}, ws);
} else if (data.type === 'abort-session') {
console.log('🛑 Abort session request:', data.sessionId);
console.log('[DEBUG] Abort session request:', data.sessionId);
const provider = data.provider || 'claude';
let success;
@@ -638,7 +748,7 @@ function handleChatConnection(ws) {
success
}));
} else if (data.type === 'cursor-abort') {
console.log('🛑 Abort Cursor session:', data.sessionId);
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId);
ws.send(JSON.stringify({
type: 'session-aborted',
@@ -677,7 +787,7 @@ function handleChatConnection(ws) {
}));
}
} catch (error) {
console.error(' Chat WebSocket error:', error.message);
console.error('[ERROR] Chat WebSocket error:', error.message);
ws.send(JSON.stringify({
type: 'error',
error: error.message
@@ -711,7 +821,7 @@ function handleShellConnection(ws) {
const initialCommand = data.initialCommand;
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
console.log('🚀 Starting shell in:', projectPath);
console.log('[INFO] Starting shell in:', projectPath);
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
if (initialCommand) {
@@ -824,7 +934,7 @@ function handleShellConnection(ws) {
let match;
while ((match = pattern.exec(data)) !== null) {
const url = match[1];
console.log('🔗 Detected URL for opening:', url);
console.log('[DEBUG] Detected URL for opening:', url);
// Send URL opening message to client
ws.send(JSON.stringify({
@@ -834,7 +944,7 @@ function handleShellConnection(ws) {
// Replace the OPEN_URL pattern with a user-friendly message
if (pattern.source.includes('OPEN_URL')) {
outputData = outputData.replace(match[0], `🌐 Opening in browser: ${url}`);
outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
}
}
});
@@ -860,7 +970,7 @@ function handleShellConnection(ws) {
});
} catch (spawnError) {
console.error(' Error spawning process:', spawnError);
console.error('[ERROR] Error spawning process:', spawnError);
ws.send(JSON.stringify({
type: 'output',
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
@@ -886,7 +996,7 @@ function handleShellConnection(ws) {
}
}
} catch (error) {
console.error(' Shell WebSocket error:', error.message);
console.error('[ERROR] Shell WebSocket error:', error.message);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'output',
@@ -905,7 +1015,7 @@ function handleShellConnection(ws) {
});
ws.on('error', (error) => {
console.error(' Shell WebSocket error:', error);
console.error('[ERROR] Shell WebSocket error:', error);
});
}
// Audio transcription endpoint
@@ -1346,28 +1456,37 @@ async function startServer() {
try {
// Initialize authentication database
await initializeDatabase();
console.log('✅ Database initialization skipped (testing)');
// Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath);
// Log Claude implementation mode
console.log('🚀 Using Claude Agents SDK for Claude integration');
console.log(`📦 Running in ${isProduction ? 'PRODUCTION' : 'DEVELOPMENT'} mode`);
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
if (!isProduction) {
console.log(`⚠️ Note: Requests will be proxied to Vite dev server at http://localhost:${process.env.VITE_PORT || 5173}`);
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
}
server.listen(PORT, '0.0.0.0', async () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
const appInstallPath = path.join(__dirname, '..');
console.log('');
console.log(c.dim('═'.repeat(63)));
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(c.dim('═'.repeat(63)));
console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
console.log('');
// Start watching the projects folder for changes
await setupProjectsWatcher();
});
} catch (error) {
console.error(' Failed to start server:', error);
console.error('[ERROR] Failed to start server:', error);
process.exit(1);
}
}

View File

@@ -20,6 +20,22 @@ const validateApiKey = (req, res, next) => {
// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
// Platform mode: use single database user
if (process.env.VITE_IS_PLATFORM === 'true') {
try {
const user = userDb.getFirstUser();
if (!user) {
return res.status(500).json({ error: 'Platform mode: No user found in database' });
}
req.user = user;
return next();
} catch (error) {
console.error('Platform mode error:', error);
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
}
}
// Normal OSS JWT validation
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
@@ -29,13 +45,13 @@ const authenticateToken = async (req, res, next) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user still exists and is active
const user = userDb.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
req.user = user;
next();
} catch (error) {
@@ -58,10 +74,25 @@ const generateToken = (user) => {
// WebSocket authentication function
const authenticateWebSocket = (token) => {
// Platform mode: bypass token validation, return first user
if (process.env.VITE_IS_PLATFORM === 'true') {
try {
const user = userDb.getFirstUser();
if (user) {
return { userId: user.id, username: user.username };
}
return null;
} catch (error) {
console.error('Platform mode WebSocket error:', error);
return null;
}
}
// Normal OSS JWT validation
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;

View File

@@ -502,16 +502,16 @@ router.post('/generate-commit-message', async (req, res) => {
*/
async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
// Create the prompt
const prompt = `You are a git commit message generator. Based on the following file changes and diffs, generate a commit message in conventional commit format.
const prompt = `Generate a conventional commit message for these changes.
REQUIREMENTS:
- Use conventional commit format: type(scope): subject
- Include a body that explains what changed and why
- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Keep subject line under 50 characters
- Wrap body at 72 characters
- Be specific and descriptive
- Return ONLY the commit message, nothing else - no markdown, no explanations, no code blocks
- Format: type(scope): subject
- Include body explaining what changed and why
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Subject under 50 chars, body wrapped at 72 chars
- Focus on user-facing changes, not implementation details
- Consider what's being added AND removed
- Return ONLY the commit message (no markdown, explanations, or code blocks)
FILES CHANGED:
${files.map(f => `- ${f}`).join('\n')}
@@ -519,7 +519,7 @@ ${files.map(f => `- ${f}`).join('\n')}
DIFFS:
${diffContext.substring(0, 4000)}
Generate the commit message now:`;
Generate the commit message:`;
try {
// Create a simple writer that collects the response

378
server/routes/projects.js Normal file
View File

@@ -0,0 +1,378 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import os from 'os';
import { addProjectManually } from '../projects.js';
const router = express.Router();
// Configure allowed workspace root (defaults to user's home directory)
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
// System-critical paths that should never be used as workspace directories
const FORBIDDEN_PATHS = [
'/',
'/etc',
'/bin',
'/sbin',
'/usr',
'/dev',
'/proc',
'/sys',
'/var',
'/boot',
'/root',
'/lib',
'/lib64',
'/opt',
'/tmp',
'/run'
];
/**
* Validates that a path is safe for workspace operations
* @param {string} requestedPath - The path to validate
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
*/
async function validateWorkspacePath(requestedPath) {
try {
// Resolve to absolute path
let absolutePath = path.resolve(requestedPath);
// Check if path is a forbidden system directory
const normalizedPath = path.normalize(absolutePath);
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
return {
valid: false,
error: 'Cannot use system-critical directories as workspace locations'
};
}
// Additional check for paths starting with forbidden directories
for (const forbidden of FORBIDDEN_PATHS) {
if (normalizedPath === forbidden ||
normalizedPath.startsWith(forbidden + path.sep)) {
// Exception: /var/tmp and similar user-accessible paths might be allowed
// but /var itself and most /var subdirectories should be blocked
if (forbidden === '/var' &&
(normalizedPath.startsWith('/var/tmp') ||
normalizedPath.startsWith('/var/folders'))) {
continue; // Allow these specific cases
}
return {
valid: false,
error: `Cannot create workspace in system directory: ${forbidden}`
};
}
}
// Try to resolve the real path (following symlinks)
let realPath;
try {
// Check if path exists to resolve real path
await fs.access(absolutePath);
realPath = await fs.realpath(absolutePath);
} catch (error) {
if (error.code === 'ENOENT') {
// Path doesn't exist yet - check parent directory
let parentPath = path.dirname(absolutePath);
try {
const parentRealPath = await fs.realpath(parentPath);
// Reconstruct the full path with real parent
realPath = path.join(parentRealPath, path.basename(absolutePath));
} catch (parentError) {
if (parentError.code === 'ENOENT') {
// Parent doesn't exist either - use the absolute path as-is
// We'll validate it's within allowed root
realPath = absolutePath;
} else {
throw parentError;
}
}
} else {
throw error;
}
}
// Resolve the workspace root to its real path
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
// Ensure the resolved path is contained within the allowed workspace root
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
realPath !== resolvedWorkspaceRoot) {
return {
valid: false,
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
};
}
// Additional symlink check for existing paths
try {
await fs.access(absolutePath);
const stats = await fs.lstat(absolutePath);
if (stats.isSymbolicLink()) {
// Verify symlink target is also within allowed root
const linkTarget = await fs.readlink(absolutePath);
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
const realTarget = await fs.realpath(resolvedTarget);
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
realTarget !== resolvedWorkspaceRoot) {
return {
valid: false,
error: 'Symlink target is outside the allowed workspace root'
};
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// Path doesn't exist - that's fine for new workspace creation
}
return {
valid: true,
resolvedPath: realPath
};
} catch (error) {
return {
valid: false,
error: `Path validation failed: ${error.message}`
};
}
}
/**
* Create a new workspace
* POST /api/projects/create-workspace
*
* Body:
* - workspaceType: 'existing' | 'new'
* - path: string (workspace path)
* - githubUrl?: string (optional, for new workspaces)
* - githubTokenId?: number (optional, ID of stored token)
* - newGithubToken?: string (optional, one-time token)
*/
router.post('/create-workspace', async (req, res) => {
try {
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
// Validate required fields
if (!workspaceType || !workspacePath) {
return res.status(400).json({ error: 'workspaceType and path are required' });
}
if (!['existing', 'new'].includes(workspaceType)) {
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
}
// Validate path safety before any operations
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
return res.status(400).json({
error: 'Invalid workspace path',
details: validation.error
});
}
const absolutePath = validation.resolvedPath;
// Handle existing workspace
if (workspaceType === 'existing') {
// Check if the path exists
try {
await fs.access(absolutePath);
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path exists but is not a directory' });
}
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Workspace path does not exist' });
}
throw error;
}
// Add the existing workspace to the project list
const project = await addProjectManually(absolutePath);
return res.json({
success: true,
project,
message: 'Existing workspace added successfully'
});
}
// Handle new workspace creation
if (workspaceType === 'new') {
// Check if path already exists
try {
await fs.access(absolutePath);
return res.status(400).json({
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
});
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// Path doesn't exist - good, we can create it
}
// Create the directory
await fs.mkdir(absolutePath, { recursive: true });
// If GitHub URL is provided, clone the repository
if (githubUrl) {
let githubToken = null;
// Get GitHub token if needed
if (githubTokenId) {
// Fetch token from database
const token = await getGithubTokenById(githubTokenId, req.user.id);
if (!token) {
// Clean up created directory
await fs.rm(absolutePath, { recursive: true, force: true });
return res.status(404).json({ error: 'GitHub token not found' });
}
githubToken = token.github_token;
} else if (newGithubToken) {
githubToken = newGithubToken;
}
// Clone the repository
try {
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
} catch (error) {
// Clean up created directory on failure
try {
await fs.rm(absolutePath, { recursive: true, force: true });
} catch (cleanupError) {
console.error('Failed to clean up directory after clone failure:', cleanupError);
// Continue to throw original error
}
throw new Error(`Failed to clone repository: ${error.message}`);
}
}
// Add the new workspace to the project list
const project = await addProjectManually(absolutePath);
return res.json({
success: true,
project,
message: githubUrl
? 'New workspace created and repository cloned successfully'
: 'New workspace created successfully'
});
}
} catch (error) {
console.error('Error creating workspace:', error);
res.status(500).json({
error: error.message || 'Failed to create workspace',
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
});
/**
* Helper function to get GitHub token from database
*/
async function getGithubTokenById(tokenId, userId) {
const { getDatabase } = await import('../database/db.js');
const db = await getDatabase();
const credential = await db.get(
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
[tokenId, userId, 'github_token']
);
// Return in the expected format (github_token field for compatibility)
if (credential) {
return {
...credential,
github_token: credential.credential_value
};
}
return null;
}
/**
* Helper function to clone a GitHub repository
*/
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
return new Promise((resolve, reject) => {
// Parse GitHub URL and inject token if provided
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
// Format: https://TOKEN@github.com/user/repo.git
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
return reject(new Error('Invalid GitHub URL format'));
}
}
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
}
});
let stdout = '';
let stderr = '';
gitProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
gitProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
gitProcess.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
// Parse git error messages to provide helpful feedback
let errorMessage = 'Git clone failed';
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
errorMessage = 'Authentication failed. Please check your GitHub token.';
} else if (stderr.includes('Repository not found')) {
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
} else if (stderr.includes('already exists')) {
errorMessage = 'Directory already exists';
} else if (stderr) {
errorMessage = stderr;
}
reject(new Error(errorMessage));
}
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
reject(new Error('Git is not installed or not in PATH'));
} else {
reject(error);
}
});
});
}
export default router;

View File

@@ -20,6 +20,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
import Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
import MobileNav from './components/MobileNav';
@@ -42,7 +43,7 @@ function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false);
const [projects, setProjects] = useState([]);
@@ -54,12 +55,14 @@ function AppContent() {
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [isInputFocused, setIsInputFocused] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('tools');
const [showQuickSettings, setShowQuickSettings] = useState(false);
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
const [showThinking, setShowThinking] = useLocalStorage('showThinking', true);
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true);
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
@@ -306,6 +309,12 @@ function AppContent() {
// Expose fetchProjects globally for component access
window.refreshProjects = fetchProjects;
// Expose openSettings function globally for component access
window.openSettings = useCallback((tab = 'tools') => {
setSettingsInitialTab(tab);
setShowSettings(true);
}, []);
// Handle URL-based session loading
useEffect(() => {
if (sessionId && projects.length > 0) {
@@ -536,8 +545,60 @@ function AppContent() {
// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState('');
if (!showVersionModal) return null;
// Clean up changelog by removing GitHub-specific metadata
const cleanChangelog = (body) => {
if (!body) return '';
return body
// Remove full commit hashes (40 character hex strings)
.replace(/\b[0-9a-f]{40}\b/gi, '')
// Remove short commit hashes (7-10 character hex strings at start of line or after dash/space)
.replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '')
// Remove "Full Changelog" links
.replace(/\*\*Full Changelog\*\*:.*$/gim, '')
// Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1)
.replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '')
// Clean up multiple consecutive empty lines
.replace(/\n\s*\n\s*\n/g, '\n\n')
// Trim whitespace
.trim();
};
const handleUpdateNow = async () => {
setIsUpdating(true);
setUpdateOutput('Starting update...\n');
setUpdateError('');
try {
// Call the backend API to run the update command
const response = await authenticatedFetch('/api/system/update', {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
setUpdateOutput(prev => prev + data.output + '\n');
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
} else {
setUpdateError(data.error || 'Update failed');
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
}
} catch (error) {
setUpdateError(error.message);
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n');
} finally {
setIsUpdating(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
@@ -546,9 +607,9 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal"
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6 space-y-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -559,7 +620,9 @@ function AppContent() {
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">A new version is ready</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{releaseInfo?.title || 'A new version is ready'}
</p>
</div>
</div>
<button
@@ -584,18 +647,57 @@ function AppContent() {
</div>
</div>
{/* Upgrade Instructions */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">How to upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
{/* Changelog */}
{releaseInfo?.body && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
{releaseInfo?.htmlUrl && (
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
>
View full release
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600 max-h-64 overflow-y-auto">
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none">
{cleanChangelog(releaseInfo.body)}
</div>
</div>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Run this command in your Claude Code UI directory to update to the latest version.
</p>
</div>
)}
{/* Update Output */}
{updateOutput && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
</div>
</div>
)}
{/* Upgrade Instructions */}
{!isUpdating && !updateOutput && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Or click "Update Now" to run the update automatically.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
@@ -603,18 +705,34 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Later
</button>
<button
onClick={() => {
// Copy command to clipboard
navigator.clipboard.writeText('git checkout main && git pull && npm install');
setShowVersionModal(false);
}}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Copy Command
{updateOutput ? 'Close' : 'Later'}
</button>
{!updateOutput && (
<>
<button
onClick={() => {
navigator.clipboard.writeText('git checkout main && git pull && npm install');
}}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Copy Command
</button>
<button
onClick={handleUpdateNow}
disabled={isUpdating}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed rounded-md transition-colors flex items-center justify-center gap-2"
>
{isUpdating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Updating...
</>
) : (
'Update Now'
)}
</button>
</>
)}
</div>
</div>
</div>
@@ -625,27 +743,78 @@ function AppContent() {
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
{!isMobile && (
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
<div
className={`flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
sidebarVisible ? 'w-80' : 'w-14'
}`}
>
<div className="h-full overflow-hidden">
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
/>
{sidebarVisible ? (
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
) : (
/* Collapsed Sidebar */
<div className="h-full flex flex-col items-center py-4 gap-4">
{/* Expand Button */}
<button
onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar"
title="Show sidebar"
>
<svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
{/* Settings Icon */}
<button
onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings"
title="Settings"
>
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button>
{/* Update Indicator */}
{updateAvailable && (
<button
onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available"
title="Update available"
>
<Sparkles className="w-5 h-5 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</button>
)}
</div>
)}
</div>
</div>
)}
@@ -691,9 +860,11 @@ function AppContent() {
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
</div>
</div>
@@ -763,6 +934,7 @@ function AppContent() {
isOpen={showSettings}
onClose={() => setShowSettings(false)}
projects={projects}
initialTab={settingsInitialTab}
/>
{/* Version Upgrade Modal */}

View File

@@ -33,11 +33,11 @@ function ApiKeysSettings() {
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
const githubRes = await fetch('/api/settings/github-tokens', {
const githubRes = await fetch('/api/settings/credentials?type=github_token', {
headers: { 'Authorization': `Bearer ${token}` }
});
const githubData = await githubRes.json();
setGithubTokens(githubData.tokens || []);
setGithubTokens(githubData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
@@ -108,15 +108,16 @@ function ApiKeysSettings() {
try {
const token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/github-tokens', {
const res = await fetch('/api/settings/credentials', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tokenName: newTokenName,
githubToken: newGithubToken
credentialName: newTokenName,
credentialType: 'github_token',
credentialValue: newGithubToken
})
});
@@ -137,7 +138,7 @@ function ApiKeysSettings() {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/github-tokens/${tokenId}`, {
await fetch(`/api/settings/credentials/${tokenId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
@@ -150,7 +151,7 @@ function ApiKeysSettings() {
const toggleGithubToken = async (tokenId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
await fetch(`/api/settings/credentials/${tokenId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
@@ -349,7 +350,7 @@ function ApiKeysSettings() {
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{token.token_name}</div>
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(token.created_at).toLocaleDateString()}
</div>

View File

@@ -19,6 +19,8 @@
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx';
@@ -57,12 +59,50 @@ function normalizeInlineCodeFences(text) {
}
}
// Unescape \n, \t, \r while protecting LaTeX formulas ($...$ and $$...$$) from being corrupted
function unescapeWithMathProtection(text) {
if (!text || typeof text !== 'string') return text;
const mathBlocks = [];
const PLACEHOLDER_PREFIX = '__MATH_BLOCK_';
const PLACEHOLDER_SUFFIX = '__';
// Extract and protect math formulas
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
const index = mathBlocks.length;
mathBlocks.push(match);
return `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`;
});
// Process escape sequences on non-math content
processedText = processedText.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
// Restore math formulas
processedText = processedText.replace(
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
(match, index) => {
return mathBlocks[parseInt(index)];
}
);
return processedText;
}
// Small wrapper to keep markdown behavior consistent in one place
const Markdown = ({ children, className }) => {
const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
@@ -514,7 +554,6 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="flex items-center gap-2">
<span className="text-lg leading-none">📝</span>
<span>View edit diff for</span>
</span>
<button
@@ -2524,10 +2563,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
content.startsWith('[Request interrupted');
if (!shouldSkip) {
// Unescape double-escaped newlines and other escape sequences
content = content.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
// Unescape with math formula protection
content = unescapeWithMathProtection(content);
converted.push({
type: messageType,
content: content,
@@ -2541,12 +2578,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (Array.isArray(msg.message.content)) {
for (const part of msg.message.content) {
if (part.type === 'text') {
// Unescape double-escaped newlines and other escape sequences
// Unescape with math formula protection
let text = part.text;
if (typeof text === 'string') {
text = text.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
text = unescapeWithMathProtection(text);
}
converted.push({
type: 'assistant',
@@ -2575,11 +2610,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
} else if (typeof msg.message.content === 'string') {
// Unescape double-escaped newlines and other escape sequences
// Unescape with math formula protection
let text = msg.message.content;
text = text.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
text = unescapeWithMathProtection(text);
converted.push({
type: 'assistant',
content: text,
@@ -2604,7 +2637,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const scrollToBottom = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
setIsUserScrolledUp(false);
// Don't reset isUserScrolledUp here - let the scroll handler manage it
// This prevents fighting with user's scroll position during streaming
}
}, []);
@@ -3523,7 +3557,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const prevTop = scrollPositionRef.current.top;
const newHeight = container.scrollHeight;
const heightDiff = newHeight - prevHeight;
// If content was added above the current view, adjust scroll position
if (heightDiff > 0 && prevTop > 0) {
container.scrollTop = prevTop + heightDiff;
@@ -3537,9 +3571,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (scrollContainerRef.current && chatMessages.length > 0 && !isLoadingSessionRef.current) {
// Only scroll if we're not in the middle of loading a session
// This prevents the "double scroll" effect during session switching
// Also reset scroll state
// Reset scroll state when switching sessions
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 200); // Delay to ensure full rendering
setTimeout(() => {
scrollToBottom();
// After scrolling, the scroll event handler will naturally set isUserScrolledUp based on position
}, 200); // Delay to ensure full rendering
}
}, [selectedSession?.id, selectedProject?.name]); // Only trigger when session/project changes
@@ -4448,6 +4485,51 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
/>
{/* Slash commands button */}
<button
type="button"
onClick={() => {
const isOpening = !showCommandMenu;
setShowCommandMenu(isOpening);
setCommandQuery('');
setSelectedCommandIndex(-1);
// When opening, ensure all commands are shown
if (isOpening) {
setFilteredCommands(slashCommands);
}
if (textareaRef.current) {
textareaRef.current.focus();
}
}}
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
title="Show all commands"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{/* Command count badge */}
{slashCommands.length > 0 && (
<span
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}
>
{slashCommands.length}
</span>
)}
</button>
{/* Clear input button - positioned to the right of token pie, only shows when there's input */}
{input.trim() && (
<button
@@ -4626,57 +4708,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Mic button - HIDDEN */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton
<MicButton
onTranscript={handleTranscript}
className="w-10 h-10 sm:w-10 sm:h-10"
/>
</div>
{/* Slash commands button */}
<button
type="button"
onClick={() => {
const isOpening = !showCommandMenu;
setShowCommandMenu(isOpening);
setCommandQuery('');
setSelectedCommandIndex(-1);
// When opening, ensure all commands are shown
if (isOpening) {
setFilteredCommands(slashCommands);
}
if (textareaRef.current) {
textareaRef.current.focus();
}
}}
className="absolute right-14 sm:right-36 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-10 sm:h-10 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 relative z-10"
title="Show all commands"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{/* Command count badge */}
{slashCommands.length > 0 && (
<span
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}
>
{slashCommands.length}
</span>
)}
</button>
{/* Send button */}
<button
type="submit"
@@ -4707,13 +4744,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</button>
{/* Hint text inside input box at bottom */}
<div className="absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block">
<div className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block transition-opacity duration-200 ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}>
{sendByCtrlEnter
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
</div>
<div className={`absolute bottom-1 left-12 right-14 text-xs text-gray-400 dark:text-gray-500 pointer-events-none sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
isInputFocused && !input.trim() ? 'opacity-100' : 'opacity-0'
}`}>
{sendByCtrlEnter
? "Ctrl+Enter to send • Tab for modes • / for commands"

View File

@@ -10,23 +10,37 @@ import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
import { api } from '../utils/api';
function CodeEditor({ file, onClose, projectPath }) {
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem('codeEditorTheme');
return savedTheme ? savedTheme === 'dark' : true;
});
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false);
const [wordWrap, setWordWrap] = useState(() => {
return localStorage.getItem('codeEditorWordWrap') === 'true';
});
const [minimapEnabled, setMinimapEnabled] = useState(() => {
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
});
const [showLineNumbers, setShowLineNumbers] = useState(() => {
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
});
const [fontSize, setFontSize] = useState(() => {
return localStorage.getItem('codeEditorFontSize') || '14';
});
const editorRef = useRef(null);
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
const gutters = {};
@@ -58,7 +72,7 @@ function CodeEditor({ file, onClose, projectPath }) {
};
})
];
}, [file.diffInfo, showDiff, isDarkMode]);
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
// Create extension to scroll to first chunk on mount
const scrollToFirstChunkExtension = useMemo(() => {
@@ -89,24 +103,28 @@ function CodeEditor({ file, onClose, projectPath }) {
];
}, [file.diffInfo, showDiff]);
// Create diff navigation panel extension
const diffNavigationPanel = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
// Create editor toolbar panel - always visible
const editorToolbarPanel = useMemo(() => {
const createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-diff-navigation-panel';
dom.className = 'cm-editor-toolbar-panel';
let currentIndex = 0;
const updatePanel = () => {
// Use getChunks API to get ALL chunks regardless of viewport
const chunksData = getChunks(view.state);
// Check if we have diff info and it's enabled
const hasDiff = file.diffInfo && showDiff;
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
dom.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
// Build the toolbar HTML
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
// Left side - diff navigation (if applicable)
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -118,41 +136,110 @@ function CodeEditor({ file, onClose, projectPath }) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
`;
}
toolbarHTML += '</div>';
// Right side - action buttons
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
// Show/hide diff button (only if there's diff info)
if (file.diffInfo) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? 'Hide diff highlighting' : 'Show diff highlighting'}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
}
</svg>
</button>
`;
}
// Settings button
toolbarHTML += `
<button class="cm-toolbar-btn cm-settings-btn" title="Editor Settings">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
`;
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
// Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? 'Collapse editor' : 'Expand editor to full width'}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
}
</svg>
</button>
`;
}
prevBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
toolbarHTML += '</div>';
toolbarHTML += '</div>';
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
dom.innerHTML = toolbarHTML;
// Attach event listeners for diff navigation
if (hasDiff) {
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
prevBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
}
// Attach event listener for toggle diff button
if (file.diffInfo) {
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
toggleDiffBtn?.addEventListener('click', () => {
setShowDiff(!showDiff);
});
}
// Attach event listener for settings button
const settingsBtn = dom.querySelector('.cm-settings-btn');
settingsBtn?.addEventListener('click', () => {
if (window.openSettings) {
window.openSettings('appearance');
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
// Attach event listener for expand button
if (isSidebar && onToggleExpand) {
const expandBtn = dom.querySelector('.cm-expand-btn');
expandBtn?.addEventListener('click', () => {
onToggleExpand();
});
}
};
updatePanel();
@@ -165,7 +252,7 @@ function CodeEditor({ file, onClose, projectPath }) {
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff]);
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
@@ -290,6 +377,57 @@ function CodeEditor({ file, onClose, projectPath }) {
setIsFullscreen(!isFullscreen);
};
// Save theme preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Save word wrap preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
}, [wordWrap]);
// Listen for settings changes from the Settings modal
useEffect(() => {
const handleStorageChange = () => {
const newTheme = localStorage.getItem('codeEditorTheme');
if (newTheme) {
setIsDarkMode(newTheme === 'dark');
}
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
if (newWordWrap !== null) {
setWordWrap(newWordWrap === 'true');
}
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
if (newShowMinimap !== null) {
setMinimapEnabled(newShowMinimap !== 'false');
}
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
if (newShowLineNumbers !== null) {
setShowLineNumbers(newShowLineNumbers !== 'false');
}
const newFontSize = localStorage.getItem('codeEditorFontSize');
if (newFontSize) {
setFontSize(newFontSize);
}
};
// Listen for storage events (changes from other tabs/windows)
window.addEventListener('storage', handleStorageChange);
// Custom event for same-window updates
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
};
}, []);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
@@ -321,14 +459,23 @@ function CodeEditor({ file, onClose, projectPath }) {
}
`}
</style>
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-white dark:bg-gray-900">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
) : (
<div className="fixed inset-0 z-40 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
)}
</>
);
}
@@ -372,8 +519,8 @@ function CodeEditor({ file, onClose, projectPath }) {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
/* Diff navigation panel styling */
.cm-diff-navigation-panel {
/* Editor toolbar panel styling */
.cm-editor-toolbar-panel {
padding: 8px 12px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
@@ -381,7 +528,8 @@ function CodeEditor({ file, onClose, projectPath }) {
font-size: 14px;
}
.cm-diff-nav-btn {
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 4px;
background: transparent;
border: none;
@@ -391,9 +539,11 @@ function CodeEditor({ file, onClose, projectPath }) {
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover {
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
@@ -403,67 +553,36 @@ function CodeEditor({ file, onClose, projectPath }) {
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`fixed inset-0 z-40 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
<div className={isSidebar ?
'bg-white dark:bg-gray-900 flex flex-col w-full h-full' :
`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
Showing changes
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">{file.path}</p>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
{file.diffInfo && (
<button
onClick={() => setShowDiff(!showDiff)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={showDiff ? "Hide diff highlighting" : "Show diff highlighting"}
>
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
</button>
)}
<button
onClick={() => setWordWrap(!wordWrap)}
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
wordWrap
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-gray-900'
}`}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Toggle theme"
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -496,13 +615,15 @@ function CodeEditor({ file, onClose, projectPath }) {
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
onClick={onClose}
@@ -522,6 +643,9 @@ function CodeEditor({ file, onClose, projectPath }) {
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
// Always show the toolbar
...editorToolbarPanel,
// Only show diff-related extensions when diff is enabled
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
@@ -533,8 +657,7 @@ function CodeEditor({ file, onClose, projectPath }) {
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension,
...diffNavigationPanel
...scrollToFirstChunkExtension
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
@@ -542,11 +665,11 @@ function CodeEditor({ file, onClose, projectPath }) {
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: '14px',
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: true,
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
@@ -565,7 +688,6 @@ function CodeEditor({ file, onClose, projectPath }) {
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">

View File

@@ -1,9 +1,63 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Button } from './ui/button';
import { X } from 'lucide-react';
function ImageViewer({ file, onClose }) {
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
const [imageUrl, setImageUrl] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let objectUrl;
const controller = new AbortController();
const loadImage = async () => {
try {
setLoading(true);
setError(null);
setImageUrl(null);
const token = localStorage.getItem('auth-token');
if (!token) {
setError('Missing authentication token');
return;
}
const response = await fetch(imagePath, {
headers: {
'Authorization': `Bearer ${token}`
},
signal: controller.signal
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
} catch (err) {
if (err.name === 'AbortError') {
return;
}
console.error('Error loading image:', err);
setError('Unable to load image');
} finally {
setLoading(false);
}
};
loadImage();
return () => {
controller.abort();
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [imagePath]);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -23,22 +77,24 @@ function ImageViewer({ file, onClose }) {
</div>
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
<img
src={imagePath}
alt={file.name}
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<div
className="text-center text-gray-500 dark:text-gray-400"
style={{ display: 'none' }}
>
<p>Unable to load image</p>
<p className="text-sm mt-2">{file.path}</p>
</div>
{loading && (
<div className="text-center text-gray-500 dark:text-gray-400">
<p>Loading image</p>
</div>
)}
{!loading && imageUrl && (
<img
src={imageUrl}
alt={file.name}
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
/>
)}
{!loading && !imageUrl && (
<div className="text-center text-gray-500 dark:text-gray-400">
<p>{error || 'Unable to load image'}</p>
<p className="text-sm mt-2 break-all">{file.path}</p>
</div>
)}
</div>
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
@@ -51,4 +107,4 @@ function ImageViewer({ file, onClose }) {
);
}
export default ImageViewer;
export default ImageViewer;

View File

@@ -11,7 +11,7 @@
* No session protection logic is implemented here - it's purely a props bridge.
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
@@ -28,13 +28,13 @@ import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { api } from '../utils/api';
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
messages,
isMobile,
isPWA,
@@ -61,6 +61,10 @@ function MainContent({
const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [editorWidth, setEditorWidth] = useState(600);
const [isResizing, setIsResizing] = useState(false);
const [editorExpanded, setEditorExpanded] = useState(false);
const resizeRef = useRef(null);
// PRD Editor state
const [showPRDEditor, setShowPRDEditor] = useState(false);
@@ -127,6 +131,11 @@ function MainContent({
const handleCloseEditor = () => {
setEditingFile(null);
setEditorExpanded(false);
};
const handleToggleEditorExpand = () => {
setEditorExpanded(!editorExpanded);
};
const handleTaskClick = (task) => {
@@ -153,6 +162,52 @@ function MainContent({
console.log('Update task status:', taskId, newStatus);
refreshTasks?.();
};
// Handle resize functionality
const handleMouseDown = (e) => {
if (isMobile) return; // Disable resize on mobile
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const handleMouseMove = (e) => {
if (!isResizing) return;
const container = resizeRef.current?.parentElement;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const newWidth = containerRect.right - e.clientX;
// Min width: 300px, Max width: 80% of container
const minWidth = 300;
const maxWidth = containerRect.width * 0.8;
if (newWidth >= minWidth && newWidth <= maxWidth) {
setEditorWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing]);
if (isLoading) {
return (
<div className="h-full flex flex-col">
@@ -234,10 +289,10 @@ function MainContent({
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div
<div
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between relative">
<div className="flex items-center space-x-2 sm:space-x-3">
{isMobile && (
<button
@@ -409,11 +464,13 @@ function MainContent({
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
{/* Content Area with Right Sidebar */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Main Content */}
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''} ${editorExpanded ? 'hidden' : ''}`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
@@ -513,14 +570,49 @@ function MainContent({
onClearLogs={() => setServerLogs([])}
/> */}
</div>
</div>
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
{editingFile && !isMobile && (
<>
{/* Resize Handle - Hidden when expanded */}
{!editorExpanded && (
<div
ref={resizeRef}
onMouseDown={handleMouseDown}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
title="Drag to resize"
>
{/* Visual indicator on hover */}
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
{/* Editor Sidebar */}
<div
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
style={editorExpanded ? {} : { width: `${editorWidth}px` }}
>
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={true}
isExpanded={editorExpanded}
onToggleExpand={handleToggleEditorExpand}
/>
</div>
</>
)}
</div>
{/* Code Editor Modal */}
{editingFile && (
{/* Code Editor Modal for Mobile */}
{editingFile && isMobile && (
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={false}
/>
)}

View File

@@ -0,0 +1,570 @@
import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { api } from '../utils/api';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
// Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new'
// Form state
const [workspacePath, setWorkspacePath] = useState('');
const [githubUrl, setGithubUrl] = useState('');
const [selectedGithubToken, setSelectedGithubToken] = useState('');
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
const [newGithubToken, setNewGithubToken] = useState('');
// UI state
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState(null);
const [availableTokens, setAvailableTokens] = useState([]);
const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false);
// Load available GitHub tokens when needed
useEffect(() => {
if (step === 2 && workspaceType === 'new' && githubUrl) {
loadGithubTokens();
}
}, [step, workspaceType, githubUrl]);
// Load path suggestions
useEffect(() => {
if (workspacePath.length > 2) {
loadPathSuggestions(workspacePath);
} else {
setPathSuggestions([]);
setShowPathDropdown(false);
}
}, [workspacePath]);
const loadGithubTokens = async () => {
try {
setLoadingTokens(true);
const response = await api.get('/settings/credentials?type=github_token');
const data = await response.json();
const activeTokens = (data.credentials || []).filter(t => t.is_active);
setAvailableTokens(activeTokens);
// Auto-select first token if available
if (activeTokens.length > 0 && !selectedGithubToken) {
setSelectedGithubToken(activeTokens[0].id.toString());
}
} catch (error) {
console.error('Error loading GitHub tokens:', error);
} finally {
setLoadingTokens(false);
}
};
const loadPathSuggestions = async (inputPath) => {
try {
// Extract the directory to browse (parent of input)
const lastSlash = inputPath.lastIndexOf('/');
const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
const response = await api.browseFilesystem(dirPath);
const data = await response.json();
if (data.suggestions) {
// Filter suggestions based on the input
const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
);
setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0);
}
} catch (error) {
console.error('Error loading path suggestions:', error);
}
};
const handleNext = () => {
setError(null);
if (step === 1) {
if (!workspaceType) {
setError('Please select whether you have an existing workspace or want to create a new one');
return;
}
setStep(2);
} else if (step === 2) {
if (!workspacePath.trim()) {
setError('Please provide a workspace path');
return;
}
// No validation for GitHub token - it's optional (only needed for private repos)
setStep(3);
}
};
const handleBack = () => {
setError(null);
setStep(step - 1);
};
const handleCreate = async () => {
setIsCreating(true);
setError(null);
try {
const payload = {
workspaceType,
path: workspacePath.trim(),
};
// Add GitHub info if creating new workspace with GitHub URL
if (workspaceType === 'new' && githubUrl) {
payload.githubUrl = githubUrl.trim();
if (tokenMode === 'stored' && selectedGithubToken) {
payload.githubTokenId = parseInt(selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
payload.newGithubToken = newGithubToken.trim();
}
}
const response = await api.createWorkspace(payload);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create workspace');
}
// Success!
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
} catch (error) {
console.error('Error creating workspace:', error);
setError(error.message || 'Failed to create workspace');
} finally {
setIsCreating(false);
}
};
const selectPathSuggestion = (suggestion) => {
setWorkspacePath(suggestion.path);
setShowPathDropdown(false);
};
return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Create New Project
</h3>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
disabled={isCreating}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Indicator */}
<div className="px-6 pt-4 pb-2">
<div className="flex items-center justify-between">
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div className="flex items-center gap-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
s < step
? 'bg-green-500 text-white'
: s === step
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
{s < step ? <Check className="w-4 h-4" /> : s}
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
</span>
</div>
{s < 3 && (
<div
className={`flex-1 h-1 mx-2 rounded ${
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6 min-h-[300px]">
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{/* Step 1: Choose workspace type */}
{step === 1 && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Do you already have a workspace, or would you like to create a new one?
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Workspace */}
<button
onClick={() => setWorkspaceType('existing')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
workspaceType === 'existing'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
Existing Workspace
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
I already have a workspace on my server and just need to add it to the project list
</p>
</div>
</div>
</button>
{/* New Workspace */}
<button
onClick={() => setWorkspaceType('new')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
workspaceType === 'new'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
New Workspace
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
Create a new workspace, optionally clone from a GitHub repository
</p>
</div>
</div>
</button>
</div>
</div>
</div>
)}
{/* Step 2: Configure workspace */}
{step === 2 && (
<div className="space-y-4">
{/* Workspace Path */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
</label>
<div className="relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
? 'Full path to your existing workspace directory'
: 'Full path where the new workspace will be created'}
</p>
</div>
{/* GitHub URL (only for new workspace) */}
{workspaceType === 'new' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub URL (Optional)
</label>
<Input
type="text"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/username/repository"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Leave empty to create an empty workspace, or provide a GitHub URL to clone
</p>
</div>
{/* GitHub Token (only if GitHub URL is provided) */}
{githubUrl && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-3 mb-4">
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
GitHub Authentication (Optional)
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
Only required for private repositories. Public repos can be cloned without authentication.
</p>
</div>
</div>
{loadingTokens ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading stored tokens...
</div>
) : availableTokens.length > 0 ? (
<>
{/* Token Selection Tabs */}
<div className="grid grid-cols-3 gap-2 mb-4">
<button
onClick={() => setTokenMode('stored')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'stored'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Stored Token
</button>
<button
onClick={() => setTokenMode('new')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'new'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
New Token
</button>
<button
onClick={() => {
setTokenMode('none');
setSelectedGithubToken('');
setNewGithubToken('');
}}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'none'
? 'bg-green-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
None (Public)
</button>
</div>
{tokenMode === 'stored' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Token
</label>
<select
value={selectedGithubToken}
onChange={(e) => setSelectedGithubToken(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
>
<option value="">-- Select a token --</option>
{availableTokens.map((token) => (
<option key={token.id} value={token.id}>
{token.credential_name}
</option>
))}
</select>
</div>
) : tokenMode === 'new' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
This token will be used only for this operation
</p>
</div>
) : null}
</>
) : (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 <strong>Public repositories</strong> don't require authentication. You can skip providing a token if cloning a public repo.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token (Optional for Public Repos)
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
</p>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Review Your Configuration
</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Workspace Type:</span>
<span className="font-medium text-gray-900 dark:text-white">
{workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Path:</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{workspacePath}
</span>
</div>
{workspaceType === 'new' && githubUrl && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Clone From:</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{githubUrl}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
<span className="text-xs text-gray-900 dark:text-white">
{tokenMode === 'stored' && selectedGithubToken
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken
? 'Using provided token'
: 'No authentication'}
</span>
</div>
</>
)}
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.'
: githubUrl
? 'A new workspace will be created and the repository will be cloned from GitHub.'
: 'An empty workspace directory will be created at the specified path.'}
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={step === 1 ? onClose : handleBack}
disabled={isCreating}
>
{step === 1 ? (
'Cancel'
) : (
<>
<ChevronLeft className="w-4 h-4 mr-1" />
Back
</>
)}
</Button>
<Button
onClick={step === 3 ? handleCreate : handleNext}
disabled={isCreating || (step === 1 && !workspaceType)}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : step === 3 ? (
<>
<Check className="w-4 h-4 mr-1" />
Create Project
</>
) : (
<>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
</div>
);
};
export default ProjectCreationWizard;

View File

@@ -26,6 +26,12 @@ const LoadingScreen = () => (
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup } = useAuth();
// Platform mode: skip all auth UI and directly render children
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
return children;
}
// Normal OSS mode: standard auth flow
if (isLoading) {
return <LoadingScreen />;
}

View File

@@ -112,7 +112,7 @@ const QuickSettingsPanel = ({
type="checkbox"
checked={autoExpandTools}
onChange={(e) => onAutoExpandChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
@@ -125,7 +125,7 @@ const QuickSettingsPanel = ({
type="checkbox"
checked={showRawParameters}
onChange={(e) => onShowRawParametersChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
@@ -138,7 +138,7 @@ const QuickSettingsPanel = ({
type="checkbox"
checked={showThinking}
onChange={(e) => onShowThinkingChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
@@ -155,7 +155,7 @@ const QuickSettingsPanel = ({
type="checkbox"
checked={autoScrollToBottom}
onChange={(e) => onAutoScrollChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
@@ -173,7 +173,7 @@ const QuickSettingsPanel = ({
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">

View File

@@ -10,7 +10,7 @@ import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import CredentialsSettings from './CredentialsSettings';
function Settings({ isOpen, onClose, projects = [] }) {
function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
const { isDarkMode, toggleDarkMode } = useTheme();
const {
tasksEnabled,
@@ -52,9 +52,26 @@ function Settings({ isOpen, onClose, projects = [] }) {
const [mcpTestResults, setMcpTestResults] = useState({});
const [mcpServerTools, setMcpServerTools] = useState({});
const [mcpToolsLoading, setMcpToolsLoading] = useState({});
const [activeTab, setActiveTab] = useState('tools');
const [activeTab, setActiveTab] = useState(initialTab);
const [jsonValidationError, setJsonValidationError] = useState('');
const [toolsProvider, setToolsProvider] = useState('claude'); // 'claude' or 'cursor'
// Code Editor settings
const [codeEditorTheme, setCodeEditorTheme] = useState(() =>
localStorage.getItem('codeEditorTheme') || 'dark'
);
const [codeEditorWordWrap, setCodeEditorWordWrap] = useState(() =>
localStorage.getItem('codeEditorWordWrap') === 'true'
);
const [codeEditorShowMinimap, setCodeEditorShowMinimap] = useState(() =>
localStorage.getItem('codeEditorShowMinimap') !== 'false' // Default true
);
const [codeEditorLineNumbers, setCodeEditorLineNumbers] = useState(() =>
localStorage.getItem('codeEditorLineNumbers') !== 'false' // Default true
);
const [codeEditorFontSize, setCodeEditorFontSize] = useState(() =>
localStorage.getItem('codeEditorFontSize') || '14'
);
// Cursor-specific states
const [cursorAllowedCommands, setCursorAllowedCommands] = useState([]);
@@ -327,8 +344,36 @@ function Settings({ isOpen, onClose, projects = [] }) {
useEffect(() => {
if (isOpen) {
loadSettings();
// Set the active tab when the modal opens
setActiveTab(initialTab);
}
}, [isOpen]);
}, [isOpen, initialTab]);
// Persist code editor settings to localStorage
useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorTheme);
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorTheme]);
useEffect(() => {
localStorage.setItem('codeEditorWordWrap', codeEditorWordWrap.toString());
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorWordWrap]);
useEffect(() => {
localStorage.setItem('codeEditorShowMinimap', codeEditorShowMinimap.toString());
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorShowMinimap]);
useEffect(() => {
localStorage.setItem('codeEditorLineNumbers', codeEditorLineNumbers.toString());
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorLineNumbers]);
useEffect(() => {
localStorage.setItem('codeEditorFontSize', codeEditorFontSize);
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorFontSize]);
const loadSettings = async () => {
try {
@@ -625,7 +670,7 @@ function Settings({ isOpen, onClose, projects = [] }) {
if (!isOpen) return null;
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-background/95">
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[9999] md:p-4 bg-background/95">
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
@@ -758,6 +803,158 @@ function Settings({ isOpen, onClose, projects = [] }) {
</div>
</div>
</div>
{/* Code Editor Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Code Editor</h3>
{/* Editor Theme */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Editor Theme
</div>
<div className="text-sm text-muted-foreground">
Default theme for the code editor
</div>
</div>
<button
onClick={() => setCodeEditorTheme(codeEditorTheme === 'dark' ? 'light' : 'dark')}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={codeEditorTheme === 'dark'}
aria-label="Toggle editor theme"
>
<span className="sr-only">Toggle editor theme</span>
<span
className={`${
codeEditorTheme === 'dark' ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{codeEditorTheme === 'dark' ? (
<Moon className="w-3.5 h-3.5 text-gray-700" />
) : (
<Sun className="w-3.5 h-3.5 text-yellow-500" />
)}
</span>
</button>
</div>
</div>
{/* Word Wrap */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Word Wrap
</div>
<div className="text-sm text-muted-foreground">
Enable word wrapping by default in the editor
</div>
</div>
<button
onClick={() => setCodeEditorWordWrap(!codeEditorWordWrap)}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={codeEditorWordWrap}
aria-label="Toggle word wrap"
>
<span className="sr-only">Toggle word wrap</span>
<span
className={`${
codeEditorWordWrap ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200`}
/>
</button>
</div>
</div>
{/* Show Minimap */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Show Minimap
</div>
<div className="text-sm text-muted-foreground">
Display a minimap for easier navigation in diff view
</div>
</div>
<button
onClick={() => setCodeEditorShowMinimap(!codeEditorShowMinimap)}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={codeEditorShowMinimap}
aria-label="Toggle minimap"
>
<span className="sr-only">Toggle minimap</span>
<span
className={`${
codeEditorShowMinimap ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200`}
/>
</button>
</div>
</div>
{/* Show Line Numbers */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Show Line Numbers
</div>
<div className="text-sm text-muted-foreground">
Display line numbers in the editor
</div>
</div>
<button
onClick={() => setCodeEditorLineNumbers(!codeEditorLineNumbers)}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={codeEditorLineNumbers}
aria-label="Toggle line numbers"
>
<span className="sr-only">Toggle line numbers</span>
<span
className={`${
codeEditorLineNumbers ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200`}
/>
</button>
</div>
</div>
{/* Font Size */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Font Size
</div>
<div className="text-sm text-muted-foreground">
Editor font size in pixels
</div>
</div>
<select
value={codeEditorFontSize}
onChange={(e) => setCodeEditorFontSize(e.target.value)}
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-24"
>
<option value="10">10px</option>
<option value="11">11px</option>
<option value="12">12px</option>
<option value="13">13px</option>
<option value="14">14px</option>
<option value="15">15px</option>
<option value="16">16px</option>
<option value="18">18px</option>
<option value="20">20px</option>
</select>
</div>
</div>
</div>
</div>
)}
@@ -818,7 +1015,7 @@ function Settings({ isOpen, onClose, projects = [] }) {
type="checkbox"
checked={skipPermissions}
onChange={(e) => setSkipPermissions(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
@@ -1578,7 +1775,7 @@ function Settings({ isOpen, onClose, projects = [] }) {
type="checkbox"
checked={cursorSkipPermissions}
onChange={(e) => setCursorSkipPermissions(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
@@ -9,6 +10,7 @@ import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx';
import TaskIndicator from './TaskIndicator';
import ProjectCreationWizard from './ProjectCreationWizard';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
@@ -39,12 +41,12 @@ const formatTimeAgo = (dateString, currentTime) => {
return date.toLocaleDateString();
};
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
onNewSession,
onSessionDelete,
onProjectDelete,
@@ -54,16 +56,16 @@ function Sidebar({
updateAvailable,
latestVersion,
currentVersion,
releaseInfo,
onShowVersionModal,
isPWA,
isMobile
isMobile,
onToggleSidebar
}) {
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
const [showNewProject, setShowNewProject] = useState(false);
const [editingName, setEditingName] = useState('');
const [newProjectPath, setNewProjectPath] = useState('');
const [creatingProject, setCreatingProject] = useState(false);
const [loadingSessions, setLoadingSessions] = useState({});
const [additionalSessions, setAdditionalSessions] = useState({});
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
@@ -74,10 +76,6 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = useState('');
const [showPathDropdown, setShowPathDropdown] = useState(false);
const [pathList, setPathList] = useState([]);
const [filteredPaths, setFilteredPaths] = useState([]);
const [selectedPathIndex, setSelectedPathIndex] = useState(-1);
// TaskMaster context
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
@@ -182,123 +180,6 @@ function Sidebar({
};
}, []);
// Load available paths for suggestions
useEffect(() => {
const loadPaths = async () => {
try {
// Get recent paths from localStorage
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
// Load common/home directory paths
const response = await api.browseFilesystem();
const data = await response.json();
if (data.suggestions) {
const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path }));
const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths];
setPathList(allPaths);
} else {
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
}
} catch (error) {
console.error('Error loading paths:', error);
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
}
};
loadPaths();
}, []);
// Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing)
useEffect(() => {
const inputValue = newProjectPath.trim();
if (inputValue.length === 0) {
setShowPathDropdown(false);
return;
}
// Show dropdown when user starts typing
setShowPathDropdown(true);
const updateSuggestions = async () => {
// First show filtered existing suggestions from pathList
const staticFiltered = pathList.filter(pathItem =>
pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) ||
pathItem.path.toLowerCase().includes(inputValue.toLowerCase())
);
// Check if input looks like a directory path for dynamic browsing
const isDirPath = inputValue.includes('/') && inputValue.length > 1;
if (isDirPath) {
try {
let dirToSearch;
// Determine which directory to search
if (inputValue.endsWith('/')) {
// User typed "/home/simos/" - search inside /home/simos
dirToSearch = inputValue.slice(0, -1);
} else {
// User typed "/home/simos/con" - search inside /home/simos for items starting with "con"
const lastSlashIndex = inputValue.lastIndexOf('/');
dirToSearch = inputValue.substring(0, lastSlashIndex);
}
// Only search if we have a valid directory path (not root only)
if (dirToSearch && dirToSearch !== '') {
const response = await api.browseFilesystem(dirToSearch);
const data = await response.json();
if (data.suggestions) {
// Filter directories that match the current input
const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1);
const dynamicPaths = data.suggestions
.filter(suggestion => {
const dirName = suggestion.name;
return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true;
})
.map(s => ({ name: s.name, path: s.path }))
.slice(0, 8);
// Combine static and dynamic suggestions, prioritize dynamic
const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8);
setFilteredPaths(combined);
setSelectedPathIndex(-1);
return;
}
}
} catch (error) {
console.debug('Dynamic browsing failed:', error.message);
}
}
// Fallback to just static filtered suggestions
setFilteredPaths(staticFiltered.slice(0, 8));
setSelectedPathIndex(-1);
};
updateSuggestions();
}, [newProjectPath, pathList]);
// Select path from dropdown (ChatInterface pattern)
const selectPath = (pathItem) => {
setNewProjectPath(pathItem.path);
setShowPathDropdown(false);
setSelectedPathIndex(-1);
};
// Save path to recent paths
const saveToRecentPaths = (path) => {
try {
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10);
localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths));
} catch (error) {
console.error('Error saving recent paths:', error);
}
};
const toggleProject = (projectName) => {
const newExpanded = new Set();
@@ -482,7 +363,6 @@ function Sidebar({
setShowNewProject(false);
setNewProjectPath('');
setShowSuggestions(false);
// Refresh projects to show the new one
if (window.refreshProjects) {
@@ -505,7 +385,6 @@ function Sidebar({
const cancelNewProject = () => {
setShowNewProject(false);
setNewProjectPath('');
setShowSuggestions(false);
};
const loadMoreSessions = async (project) => {
@@ -569,10 +448,27 @@ function Sidebar({
};
return (
<div
className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
>
<>
{/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
{showNewProject && ReactDOM.createPortal(
<ProjectCreationWizard
onClose={() => setShowNewProject(false)}
onProjectCreated={(project) => {
// Refresh projects list after creation
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
}}
/>,
document.body
)}
<div
className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
>
{/* Header */}
<div className="md:p-4 md:border-b md:border-border">
{/* Desktop Header */}
@@ -586,34 +482,24 @@ function Sidebar({
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
</div>
</div>
<div className="flex gap-2">
{onToggleSidebar && (
<Button
variant="ghost"
size="sm"
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
onClick={onToggleSidebar}
title="Hide sidebar"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Button>
<Button
variant="default"
size="sm"
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-4 h-4" />
</Button>
</div>
)}
</div>
{/* Mobile Header */}
@@ -656,266 +542,10 @@ function Sidebar({
</div>
</div>
</div>
{/* New Project Form */}
{showNewProject && (
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
{/* Desktop Form */}
<div className="hidden md:block space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<FolderPlus className="w-4 h-4" />
Create New Project
</div>
<div className="relative">
<Input
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
placeholder="/path/to/project or relative/path"
className="text-sm focus:ring-2 focus:ring-primary/20"
autoFocus
onKeyDown={(e) => {
// Handle path dropdown navigation (ChatInterface pattern)
if (showPathDropdown && filteredPaths.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedPathIndex(prev =>
prev < filteredPaths.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedPathIndex(prev =>
prev > 0 ? prev - 1 : filteredPaths.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedPathIndex >= 0) {
selectPath(filteredPaths[selectedPathIndex]);
} else if (filteredPaths.length > 0) {
selectPath(filteredPaths[0]);
} else {
createNewProject();
}
return;
} else if (e.key === 'Escape') {
e.preventDefault();
setShowPathDropdown(false);
return;
} else if (e.key === 'Tab') {
e.preventDefault();
if (selectedPathIndex >= 0) {
selectPath(filteredPaths[selectedPathIndex]);
} else if (filteredPaths.length > 0) {
selectPath(filteredPaths[0]);
}
return;
}
}
// Regular input handling
if (e.key === 'Enter') {
createNewProject();
}
if (e.key === 'Escape') {
cancelNewProject();
}
}}
/>
{/* Path dropdown (ChatInterface pattern) */}
{showPathDropdown && filteredPaths.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
{filteredPaths.map((pathItem, index) => (
<div
key={pathItem.path}
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
index === selectedPathIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
}`}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectPath(pathItem);
}}
>
<div className="flex items-center gap-2">
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<div>
<div className="font-medium text-sm">{pathItem.name}</div>
<div className="text-xs text-muted-foreground font-mono">
{pathItem.path}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
>
{creatingProject ? 'Creating...' : 'Create Project'}
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelNewProject}
disabled={creatingProject}
className="h-8 text-xs hover:bg-accent transition-colors"
>
Cancel
</Button>
</div>
</div>
{/* Mobile Form - Simple Overlay */}
<div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
<div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
<FolderPlus className="w-3 h-3 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">New Project</h2>
</div>
</div>
<button
onClick={cancelNewProject}
disabled={creatingProject}
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="space-y-3">
<div className="relative">
<Input
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
placeholder="/path/to/project or relative/path"
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
autoFocus
onKeyDown={(e) => {
// Handle path dropdown navigation (same as desktop)
if (showPathDropdown && filteredPaths.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedPathIndex(prev =>
prev < filteredPaths.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedPathIndex(prev =>
prev > 0 ? prev - 1 : filteredPaths.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedPathIndex >= 0) {
selectPath(filteredPaths[selectedPathIndex]);
} else if (filteredPaths.length > 0) {
selectPath(filteredPaths[0]);
} else {
createNewProject();
}
return;
} else if (e.key === 'Escape') {
e.preventDefault();
setShowPathDropdown(false);
return;
}
}
// Regular input handling
if (e.key === 'Enter') {
createNewProject();
}
if (e.key === 'Escape') {
cancelNewProject();
}
}}
style={{
fontSize: '16px', // Prevents zoom on iOS
WebkitAppearance: 'none'
}}
/>
{/* Mobile Path dropdown */}
{showPathDropdown && filteredPaths.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
{filteredPaths.map((pathItem, index) => (
<div
key={pathItem.path}
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
index === selectedPathIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
}`}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectPath(pathItem);
}}
>
<div className="flex items-center gap-2">
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<div>
<div className="font-medium text-sm">{pathItem.name}</div>
<div className="text-xs text-muted-foreground font-mono">
{pathItem.path}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
onClick={cancelNewProject}
disabled={creatingProject}
variant="outline"
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
>
Cancel
</Button>
<Button
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
>
{creatingProject ? 'Creating...' : 'Create'}
</Button>
</div>
</div>
{/* Safe area for mobile */}
<div className="h-4" />
</div>
</div>
</div>
)}
{/* Search Filter */}
{/* Search Filter and Actions */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -934,6 +564,39 @@ function Sidebar({
</button>
)}
</div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div>
)}
@@ -1611,8 +1274,10 @@ function Sidebar({
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
</div>
</Button>
</div>
@@ -1630,8 +1295,10 @@ function Sidebar({
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
</div>
</button>
</div>
@@ -1664,6 +1331,7 @@ function Sidebar({
</Button>
</div>
</div>
</>
);
}

View File

@@ -29,6 +29,15 @@ export const AuthProvider = ({ children }) => {
// Check authentication status on mount
useEffect(() => {
// Platform mode: skip all auth checks, set dummy user
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
setUser({ username: 'platform-user' });
setNeedsSetup(false);
setIsLoading(false);
return;
}
// Normal OSS mode: check auth status
checkAuthStatus();
}, []);
@@ -36,22 +45,22 @@ export const AuthProvider = ({ children }) => {
try {
setIsLoading(true);
setError(null);
// Check if system needs setup
const statusResponse = await api.auth.status();
const statusData = await statusResponse.json();
if (statusData.needsSetup) {
setNeedsSetup(true);
setIsLoading(false);
return;
}
// If we have a token, verify it
if (token) {
try {
const userResponse = await api.auth.user();
if (userResponse.ok) {
const userData = await userResponse.json();
setUser(userData.user);
@@ -70,7 +79,7 @@ export const AuthProvider = ({ children }) => {
}
}
} catch (error) {
console.error('Auth status check failed:', error);
console.error('[AuthContext] Auth status check failed:', error);
setError('Failed to check authentication status');
} finally {
setIsLoading(false);

View File

@@ -5,28 +5,39 @@ import { version } from '../../package.json';
export const useVersionCheck = (owner, repo) => {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestVersion, setLatestVersion] = useState(null);
const [releaseInfo, setReleaseInfo] = useState(null);
useEffect(() => {
const checkVersion = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
const data = await response.json();
// Handle the case where there might not be any releases
if (data.tag_name) {
const latest = data.tag_name.replace(/^v/, '');
setLatestVersion(latest);
setUpdateAvailable(version !== latest);
// Store release information
setReleaseInfo({
title: data.name || data.tag_name,
body: data.body || '',
htmlUrl: data.html_url || `https://github.com/${owner}/${repo}/releases/latest`,
publishedAt: data.published_at
});
} else {
// No releases found, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
setReleaseInfo(null);
}
} catch (error) {
console.error('Version check failed:', error);
// On error, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
setReleaseInfo(null);
}
};
@@ -35,5 +46,5 @@ export const useVersionCheck = (owner, repo) => {
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version };
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo };
};

View File

@@ -79,7 +79,7 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--input: 220 13% 46%;
--ring: 217.2 91.2% 59.8%;
}
}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import 'katex/dist/katex.min.css'
// Clean up stale service workers on app load to prevent caching issues after builds
if ('serviceWorker' in navigator) {
@@ -18,4 +19,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
)

View File

@@ -71,6 +71,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ path }),
}),
createWorkspace: (workspaceData) =>
authenticatedFetch('/api/projects/create-workspace', {
method: 'POST',
body: JSON.stringify(workspaceData),
}),
readFile: (projectName, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
saveFile: (projectName, filePath, content) =>