Compare commits
265 Commits
v1.9.0
...
1f903baf2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f903baf2c | ||
|
|
5e3a7b69d7 | ||
|
|
23801e9cc1 | ||
|
|
4f6ff9260d | ||
|
|
49061bc7a3 | ||
|
|
50e097d4ac | ||
|
|
f986004319 | ||
|
|
f488a346ef | ||
|
|
82efac4704 | ||
|
|
81697d0e73 | ||
|
|
27bf09b0c1 | ||
|
|
7ccbc8d92d | ||
|
|
597e9c54b7 | ||
|
|
cccd915c33 | ||
|
|
0207a1f3a3 | ||
|
|
38a593c97f | ||
|
|
fc369d047e | ||
|
|
9d8e92b5a4 | ||
|
|
07f1d9a4a8 | ||
|
|
e853d29584 | ||
|
|
09af23bcaf | ||
|
|
520e3f2280 | ||
|
|
151e8ee808 | ||
|
|
2cfcae049b | ||
|
|
8723393b66 | ||
|
|
29b80b1905 | ||
|
|
c55e996f3a | ||
|
|
e7800c494f | ||
|
|
374fe35915 | ||
|
|
33b0ea4c4a | ||
|
|
412102c531 | ||
|
|
afe1be7fca | ||
|
|
42f13e151c | ||
|
|
272eb00602 | ||
|
|
f891316ec0 | ||
|
|
1ed3358cbd | ||
|
|
c1e025b665 | ||
|
|
cf3d23ee31 | ||
|
|
e7d6c40452 | ||
|
|
216932e7f9 | ||
|
|
e9719256fc | ||
|
|
55caaf060c | ||
|
|
f9c7321c8c | ||
|
|
88bda6e5c0 | ||
|
|
86b421c790 | ||
|
|
41ef84c283 | ||
|
|
53224e47b6 | ||
|
|
bbb51dbf99 | ||
|
|
2d06cae0ca | ||
|
|
14fb81586c | ||
|
|
4d2b592ec6 | ||
|
|
4957220a05 | ||
|
|
3debc3a249 | ||
|
|
5512e2e15b | ||
|
|
1b42dba902 | ||
|
|
ede56ad81b | ||
|
|
36094fb73f | ||
|
|
57828653bf | ||
|
|
8ef0951901 | ||
|
|
ab50c5c1a8 | ||
|
|
6726e8f44e | ||
|
|
07f89e5240 | ||
|
|
8a675a713b | ||
|
|
5724c11253 | ||
|
|
c7b9976986 | ||
|
|
f16e3e763d | ||
|
|
477bc404b0 | ||
|
|
ae5a21cd6e | ||
|
|
b2c69d6ea8 | ||
|
|
8825baf5b4 | ||
|
|
0d1a3df1f7 | ||
|
|
80732923b5 | ||
|
|
6362d35d66 | ||
|
|
10bfeed614 | ||
|
|
dab089b29f | ||
|
|
38745bdf85 | ||
|
|
9da7c1cbae | ||
|
|
844677caee | ||
|
|
e1c67fd5d0 | ||
|
|
9cd0cfc88f | ||
|
|
09f1021c59 | ||
|
|
cf0f60bc48 | ||
|
|
053d94ab9d | ||
|
|
79f7bf9a63 | ||
|
|
e85cc746b1 | ||
|
|
cc3368c591 | ||
|
|
5131d2ae27 | ||
|
|
394b95ae29 | ||
|
|
4948aa3d64 | ||
|
|
6e07f140e3 | ||
|
|
fea8e30725 | ||
|
|
9f534ce15b | ||
|
|
8cb34a73b5 | ||
|
|
74640a7f31 | ||
|
|
5800d84255 | ||
|
|
33c70a372d | ||
|
|
396f058b46 | ||
|
|
b899695772 | ||
|
|
a173817d37 | ||
|
|
73375d7653 | ||
|
|
1d48b78af2 | ||
|
|
7928285ed0 | ||
|
|
92cbb3e7d9 | ||
|
|
0517ee609e | ||
|
|
3bbf38125a | ||
|
|
9e03acb0db | ||
|
|
515ad3b336 | ||
|
|
b68a903781 | ||
|
|
a08deee6b7 | ||
|
|
9cd1b5811a | ||
|
|
ee43adb311 | ||
|
|
740f3a7f0e | ||
|
|
e1f2af1a34 | ||
|
|
50f8c4ba72 | ||
|
|
1e8e52ce8d | ||
|
|
133c762eea | ||
|
|
4216676395 | ||
|
|
ddb26c7652 | ||
|
|
b3c6e95971 | ||
|
|
f8d1ec7b9e | ||
|
|
e73960ae78 | ||
|
|
1f6c0c3899 | ||
|
|
9da8e69476 | ||
|
|
15e4db386f | ||
|
|
66e85fb2c1 | ||
|
|
42b2d5e1d9 | ||
|
|
d3c4821258 | ||
|
|
72c4b0749e | ||
|
|
35e140b941 | ||
|
|
b70728254b | ||
|
|
64ebbaf387 | ||
|
|
cdaff9d146 | ||
|
|
3f66179e72 | ||
|
|
c654f489af | ||
|
|
97ebef016a | ||
|
|
ef44942767 | ||
|
|
7b63a68e7e | ||
|
|
005033136b | ||
|
|
ee3917b3f9 | ||
|
|
8fb43d358c | ||
|
|
4c40a33255 | ||
|
|
4086fdaa4e | ||
|
|
124c1ac600 | ||
|
|
9efe433d99 | ||
|
|
189a1b174c | ||
|
|
04a0ff311e | ||
|
|
efae890e34 | ||
|
|
ea33810a4f | ||
|
|
4fe6cc4272 | ||
|
|
ba70ad8e81 | ||
|
|
b066ec4c01 | ||
|
|
104e4260a7 | ||
|
|
8af982e706 | ||
|
|
81c0773358 | ||
|
|
29783f609f | ||
|
|
ea19bd9a00 | ||
|
|
6d4e5017d0 | ||
|
|
9b217ada0d | ||
|
|
04efaa41f6 | ||
|
|
5aef9c683a | ||
|
|
724cb5bb5c | ||
|
|
4e163c8c10 | ||
|
|
b315360f8a | ||
|
|
04821b8ad5 | ||
|
|
00278a13d8 | ||
|
|
676d2415a0 | ||
|
|
babe96eedd | ||
|
|
60c8bda755 | ||
|
|
d98b112302 | ||
|
|
8186c4039f | ||
|
|
02c13b0794 | ||
|
|
a8c141cb8e | ||
|
|
fbbf7465fb | ||
|
|
7a173071f1 | ||
|
|
6bf3696991 | ||
|
|
d822a96818 | ||
|
|
19bb741af0 | ||
|
|
1f4cd16b89 | ||
|
|
09688a09ca | ||
|
|
1cc3f61b81 | ||
|
|
89c9aec5b7 | ||
|
|
e74a813093 | ||
|
|
73a0b5bebd | ||
|
|
3a72a262a9 | ||
|
|
e952cf0a42 | ||
|
|
18d0874142 | ||
|
|
33e70c4b55 | ||
|
|
98c8b14b4f | ||
|
|
544c72434a | ||
|
|
b892985700 | ||
|
|
8c629a1a05 | ||
|
|
2df8c8e786 | ||
|
|
f91f9f702d | ||
|
|
6219c273a2 | ||
|
|
33834d808b | ||
|
|
abe8cd46a2 | ||
|
|
521fce32d0 | ||
|
|
2815e206dc | ||
|
|
71e400c54f | ||
|
|
05b2b59e23 | ||
|
|
ad219c8716 | ||
|
|
ed65399dfb | ||
|
|
2fb1e1cfb0 | ||
|
|
0f4b3666fc | ||
|
|
69b7b59f00 | ||
|
|
51431832d8 | ||
|
|
1e50cfdad6 | ||
|
|
289c2334e0 | ||
|
|
de8c4d1845 | ||
|
|
23de8c7863 | ||
|
|
041c72b160 | ||
|
|
519b5e5209 | ||
|
|
7ab14750de | ||
|
|
255aed0b01 | ||
|
|
b416e542c7 | ||
|
|
43cbbb10d9 | ||
|
|
003b64f8f0 | ||
|
|
401223dcd5 | ||
|
|
499e33d910 | ||
|
|
0181883c8a | ||
|
|
a100aa598c | ||
|
|
c875907f55 | ||
|
|
b2c16002e4 | ||
|
|
c7dbab086b | ||
|
|
b31f7afdf5 | ||
|
|
06d17eb22e | ||
|
|
57739a659f | ||
|
|
a5813e66d9 | ||
|
|
18ea4a19dd | ||
|
|
1c95c598eb | ||
|
|
72e97c4fbc | ||
|
|
b5d1fed354 | ||
|
|
d1733f34e0 | ||
|
|
fefcc0f338 | ||
|
|
36f8f50d63 | ||
|
|
4e14222487 | ||
|
|
e2ba000e86 | ||
|
|
64e2909f0f | ||
|
|
6541760eb7 | ||
|
|
50454175c9 | ||
|
|
d6ceb222c3 | ||
|
|
9cfb7e659d | ||
|
|
018b337871 | ||
|
|
0b8b1d0677 | ||
|
|
2e1e5b463a | ||
|
|
cafe18961e | ||
|
|
8f3a97b8b0 | ||
|
|
b612035b20 | ||
|
|
c4e196692c | ||
|
|
da6f35adc9 | ||
|
|
bf1b3e7376 | ||
|
|
df726c2d4f | ||
|
|
5af3706d69 | ||
|
|
53c1af33fa | ||
|
|
1bc2cf49ec | ||
|
|
d2f02558a1 | ||
|
|
a39a5fdd97 | ||
|
|
74607971a2 | ||
|
|
eb835d21b2 | ||
|
|
eda89ef147 | ||
|
|
9079326ac5 | ||
|
|
7a087039c9 | ||
|
|
de1f5d36f3 | ||
|
|
d9eef6dcfe | ||
|
|
c9afc2e851 |
29
.env.example
@@ -1,5 +1,15 @@
|
||||
# Claude Code UI Environment Configuration
|
||||
# CloudCLI 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
|
||||
@@ -11,10 +21,25 @@ PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=5173
|
||||
|
||||
# Host/IP to bind servers to (default: 0.0.0.0 for all interfaces)
|
||||
# Use 127.0.0.1 to restrict to localhost only
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||
# CLAUDE_CLI_PATH=claude
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Path to the authentication database file
|
||||
# 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
|
||||
|
||||
57
.npmignore
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
.env*
|
||||
.gitignore
|
||||
.nvmrc
|
||||
.release-it.json
|
||||
release.sh
|
||||
postcss.config.js
|
||||
vite.config.js
|
||||
tailwind.config.js
|
||||
|
||||
# Database files
|
||||
authdb/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# AI specific
|
||||
.claude/
|
||||
.cursor/
|
||||
.roo/
|
||||
.taskmaster/
|
||||
.cline/
|
||||
.windsurf/
|
||||
.serena/
|
||||
CLAUDE.md
|
||||
.mcp.json
|
||||
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
Before Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@@ -2,20 +2,39 @@
|
||||
"git": {
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||
"requireBranch": "main",
|
||||
"requireCleanWorkingDir": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "Claude Code UI v${version}",
|
||||
"releaseNotes": {
|
||||
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||
"excludeMatches": ["viper151"]
|
||||
}
|
||||
"releaseName": "CloudCLI UI v${version}"
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": ["npm run build"]
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"header": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n",
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{ "type": "feat", "section": "New Features" },
|
||||
{ "type": "feature", "section": "New Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "perf", "section": "Performance" },
|
||||
{ "type": "refactor", "section": "Refactoring" },
|
||||
{ "type": "docs", "section": "Documentation" },
|
||||
{ "type": "style", "section": "Styling" },
|
||||
{ "type": "chore", "section": "Maintenance" },
|
||||
{ "type": "ci", "section": "CI/CD" },
|
||||
{ "type": "test", "section": "Tests" },
|
||||
{ "type": "build", "section": "Build" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)
|
||||
|
||||
### New Features
|
||||
|
||||
* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))
|
||||
* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))
|
||||
|
||||
## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))
|
||||
|
||||
## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)
|
||||
|
||||
### New Features
|
||||
|
||||
* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))
|
||||
* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)
|
||||
* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))
|
||||
156
CONTRIBUTING.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Contributing to CloudCLI UI
|
||||
|
||||
Thanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work.
|
||||
- **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work.
|
||||
- **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) 22 or later
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
5. Create a branch for your changes:
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
claudecodeui/
|
||||
├── src/ # React frontend (Vite + Tailwind)
|
||||
│ ├── components/ # UI components
|
||||
│ ├── contexts/ # React context providers
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── i18n/ # Internationalization and translations
|
||||
│ ├── lib/ # Shared frontend libraries
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Frontend utilities
|
||||
├── server/ # Express backend
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ ├── database/ # SQLite database layer
|
||||
│ └── tools/ # CLI tool integrations
|
||||
├── shared/ # Code shared between client and server
|
||||
└── public/ # Static assets, icons, PWA manifest
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- `npm run dev` — Start both the frontend and backend in development mode
|
||||
- `npm run build` — Create a production build
|
||||
- `npm run server` — Start only the backend server
|
||||
- `npm run client` — Start only the Vite dev server
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reference the issue number in your PR if one exists
|
||||
- Describe how to reproduce the bug in your PR description
|
||||
- Add a screenshot or recording for visual bugs
|
||||
|
||||
### New Features
|
||||
|
||||
- Keep the scope focused — one feature per PR
|
||||
- Include screenshots or recordings for UI changes
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation improvements are always welcome
|
||||
- Keep language clear and concise
|
||||
|
||||
## Commit Convention
|
||||
|
||||
We follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format:
|
||||
|
||||
```
|
||||
<type>(optional scope): <description>
|
||||
```
|
||||
|
||||
Use imperative, present tense: "add feature" not "added feature" or "adds feature".
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `feat` | A new feature |
|
||||
| `fix` | A bug fix |
|
||||
| `perf` | A performance improvement |
|
||||
| `refactor` | Code change that neither fixes a bug nor adds a feature |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | CSS, formatting, visual changes |
|
||||
| `chore` | Maintenance, dependencies, config |
|
||||
| `ci` | CI/CD pipeline changes |
|
||||
| `test` | Adding or updating tests |
|
||||
| `build` | Build system changes |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
feat: add conversation search
|
||||
feat(i18n): add Japanese language support
|
||||
fix: redirect unauthenticated users to login
|
||||
fix(editor): syntax highlighting for .env files
|
||||
perf: lazy load code editor component
|
||||
refactor(chat): extract message list component
|
||||
docs: update API configuration guide
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Add `!` after the type or include `BREAKING CHANGE:` in the commit footer:
|
||||
|
||||
```bash
|
||||
feat!: redesign settings page layout
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Give your PR a clear, descriptive title following the commit convention above
|
||||
- Fill in the PR description with what changed and why
|
||||
- Link any related issues
|
||||
- Include screenshots for UI changes
|
||||
- Make sure the build passes (`npm run build`)
|
||||
- Keep PRs focused — avoid unrelated changes
|
||||
|
||||
## Releases
|
||||
|
||||
Releases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog).
|
||||
|
||||
```bash
|
||||
npm run release # interactive (prompts for version bump)
|
||||
npm run release -- patch # patch release
|
||||
npm run release -- minor # minor release
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Bumps the version based on commit types (`feat` = minor, `fix` = patch)
|
||||
- Generates categorized release notes
|
||||
- Updates `CHANGELOG.md`
|
||||
- Creates a git tag and GitHub Release
|
||||
- Publishes to npm
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE).
|
||||
346
README.ja.md
Normal file
@@ -0,0 +1,346 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (別名 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>デスクトップビュー</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>プロジェクト概要とチャットを表示するメインインターフェース</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>モバイル体験</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>タッチナビゲーション対応のレスポンシブモバイルデザイン</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 選択</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Claude Code、Cursor CLI、Codex から選択</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 機能
|
||||
|
||||
- **レスポンシブデザイン** - デスクトップ、タブレット、モバイルでシームレスに動作し、モバイルからも Claude Code、Cursor、Codex を使用可能
|
||||
- **インタラクティブチャットインターフェース** - Claude Code、Cursor、Codex とシームレスに通信する組み込みチャットインターフェース
|
||||
- **統合シェルターミナル** - 組み込みシェル機能による Claude Code、Cursor CLI、Codex への直接アクセス
|
||||
- **ファイルエクスプローラー** - シンタックスハイライトとライブ編集対応のインタラクティブファイルツリー
|
||||
- **Git エクスプローラー** - 変更の確認、ステージング、コミット。ブランチの切り替えも可能
|
||||
- **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡
|
||||
- **TaskMaster AI 統合** *(オプション)* - AI 駆動のタスク計画、PRD 解析、ワークフロー自動化による高度なプロジェクト管理
|
||||
- **モデル互換性** - Claude Sonnet 4.5、Opus 4.5、GPT-5.2 に対応
|
||||
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### 前提条件
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 以上
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) のインストールと設定、および/または
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) のインストールと設定、および/または
|
||||
- [Codex](https://developers.openai.com/codex) のインストールと設定
|
||||
|
||||
### ワンクリック実行(推奨)
|
||||
|
||||
インストール不要、直接実行:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
サーバーが起動し、`http://localhost:3001`(または設定した PORT)でアクセスできます。
|
||||
|
||||
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
|
||||
### グローバルインストール(定期的に使用する場合)
|
||||
|
||||
頻繁に使用する場合は、一度だけグローバルインストール:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
シンプルなコマンドで起動:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**再起動**: Ctrl+C で停止し、`claude-code-ui` を再度実行します。
|
||||
|
||||
**アップデート**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI の使い方
|
||||
|
||||
グローバルインストール後、`claude-code-ui` と `cloudcli` コマンドが使用できます:
|
||||
|
||||
| コマンド / オプション | 短縮形 | 説明 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` または `claude-code-ui` | | サーバーを起動(デフォルト) |
|
||||
| `cloudcli start` | | サーバーを明示的に起動 |
|
||||
| `cloudcli status` | | 設定とデータの場所を表示 |
|
||||
| `cloudcli update` | | 最新バージョンに更新 |
|
||||
| `cloudcli help` | | ヘルプ情報を表示 |
|
||||
| `cloudcli version` | | バージョン情報を表示 |
|
||||
| `--port <port>` | `-p` | サーバーポートを設定(デフォルト: 3001) |
|
||||
| `--database-path <path>` | | カスタムデータベースの場所を設定 |
|
||||
|
||||
**例:**
|
||||
```bash
|
||||
cloudcli # デフォルト設定で起動
|
||||
cloudcli -p 8080 # カスタムポートで起動
|
||||
cloudcli status # 現在の設定を表示
|
||||
```
|
||||
|
||||
### バックグラウンドサービスとして実行(本番環境推奨)
|
||||
|
||||
本番環境では、PM2(Process Manager 2)を使用して Claude Code UI をバックグラウンドサービスとして実行します:
|
||||
|
||||
#### PM2 のインストール
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### バックグラウンドサービスとして起動
|
||||
|
||||
```bash
|
||||
# バックグラウンドでサーバーを起動
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# または短いエイリアスを使用
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# カスタムポートで起動
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### システム起動時の自動起動
|
||||
|
||||
システム起動時に Claude Code UI を自動的に起動するには:
|
||||
|
||||
```bash
|
||||
# プラットフォーム用の起動スクリプトを生成
|
||||
pm2 startup
|
||||
|
||||
# 現在のプロセスリストを保存
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### ローカル開発インストール
|
||||
|
||||
1. **リポジトリをクローン:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **依存関係をインストール:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **環境を設定:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# お好みの設定で .env を編集
|
||||
```
|
||||
|
||||
4. **アプリケーションを起動:**
|
||||
```bash
|
||||
# 開発モード(ホットリロード付き)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
アプリケーションは .env で指定したポートで起動します
|
||||
|
||||
5. **ブラウザを開く:**
|
||||
- 開発: `http://localhost:3001`
|
||||
|
||||
## セキュリティとツール設定
|
||||
|
||||
**重要なお知らせ**: すべての Claude Code ツールは**デフォルトで無効**になっています。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。
|
||||
|
||||
### ツールの有効化
|
||||
|
||||
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
|
||||
|
||||
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
|
||||
3. **選択的に有効化** - 必要なツールのみを有効にする
|
||||
4. **設定を適用** - 環境設定はローカルに保存されます
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*ツール設定インターフェース - 必要なものだけを有効にしましょう*
|
||||
|
||||
</div>
|
||||
|
||||
**推奨アプローチ**: 基本的なツールから有効にし、必要に応じて追加してください。これらの設定はいつでも調整できます。
|
||||
|
||||
## TaskMaster AI 統合 *(オプション)*
|
||||
|
||||
Claude Code UI は、高度なプロジェクト管理と AI 駆動のタスク計画のための **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(別名 claude-task-master)統合をサポートしています。
|
||||
|
||||
提供機能
|
||||
- PRD(製品要件ドキュメント)からの AI 駆動タスク生成
|
||||
- スマートなタスク分解と依存関係管理
|
||||
- ビジュアルタスクボードと進捗追跡
|
||||
|
||||
**セットアップとドキュメント**: インストール手順、設定ガイド、使用例は [TaskMaster AI GitHub リポジトリ](https://github.com/eyaltoledano/claude-task-master)をご覧ください。
|
||||
インストール後、設定から有効にできます
|
||||
|
||||
|
||||
## 使用ガイド
|
||||
|
||||
### 主要機能
|
||||
|
||||
#### プロジェクト管理
|
||||
Claude Code、Cursor、Codex のセッションが利用可能な場合、自動的に検出しプロジェクトとしてグループ化します
|
||||
- **プロジェクト操作** - プロジェクトの名前変更、削除、整理
|
||||
- **スマートナビゲーション** - 最近のプロジェクトやセッションへのクイックアクセス
|
||||
- **MCP サポート** - UI から独自の MCP サーバーを追加
|
||||
|
||||
#### チャットインターフェース
|
||||
- **レスポンシブチャットまたは Claude Code/Cursor CLI/Codex CLI を使用** - アダプティブチャットインターフェースを使用するか、シェルボタンで選択した CLI に接続できます
|
||||
- **リアルタイム通信** - WebSocket 接続で選択した CLI(Claude Code/Cursor/Codex)からレスポンスをストリーミング
|
||||
- **セッション管理** - 以前の会話を再開、または新しいセッションを開始
|
||||
- **メッセージ履歴** - タイムスタンプとメタデータ付きの完全な会話履歴
|
||||
- **マルチフォーマット対応** - テキスト、コードブロック、ファイル参照
|
||||
|
||||
#### ファイルエクスプローラーとエディター
|
||||
- **インタラクティブファイルツリー** - 展開/折りたたみナビゲーションでプロジェクト構造を閲覧
|
||||
- **ライブファイル編集** - インターフェースで直接ファイルの読み取り、変更、保存
|
||||
- **シンタックスハイライト** - 複数のプログラミング言語に対応
|
||||
- **ファイル操作** - ファイルやディレクトリの作成、名前変更、削除
|
||||
|
||||
#### Git エクスプローラー
|
||||
|
||||
|
||||
#### TaskMaster AI 統合 *(オプション)*
|
||||
- **ビジュアルタスクボード** - 開発タスク管理のためのカンバンスタイルインターフェース
|
||||
- **PRD パーサー** - 製品要件ドキュメントを作成し、構造化されたタスクに変換
|
||||
- **進捗追跡** - リアルタイムのステータス更新と完了追跡
|
||||
|
||||
#### セッション管理
|
||||
- **セッション永続化** - すべての会話を自動保存
|
||||
- **セッション整理** - プロジェクトとタイムスタンプでセッションをグループ化
|
||||
- **セッション操作** - 会話履歴の名前変更、削除、エクスポート
|
||||
- **クロスデバイス同期** - どのデバイスからでもセッションにアクセス
|
||||
|
||||
### モバイルアプリ
|
||||
- **レスポンシブデザイン** - すべての画面サイズに最適化
|
||||
- **タッチフレンドリーインターフェース** - スワイプジェスチャーとタッチナビゲーション
|
||||
- **モバイルナビゲーション** - 親指で操作しやすいボトムタブバー
|
||||
- **アダプティブレイアウト** - 折りたたみ可能なサイドバーとスマートコンテンツ優先順位
|
||||
- **ホーム画面にショートカットを追加** - ホーム画面にショートカットを追加すると、アプリが PWA のように動作します
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
### システム概要
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### バックエンド (Node.js + Express)
|
||||
- **Express サーバー** - 静的ファイル配信付きの RESTful API
|
||||
- **WebSocket サーバー** - チャットとプロジェクト更新のための通信
|
||||
- **エージェント統合 (Claude Code / Cursor CLI / Codex)** - プロセスの生成と管理
|
||||
- **ファイルシステム API** - プロジェクト向けファイルブラウザの公開
|
||||
|
||||
### フロントエンド (React + Vite)
|
||||
- **React 18** - hooks を使用したモダンなコンポーネントアーキテクチャ
|
||||
- **CodeMirror** - シンタックスハイライト対応の高度なコードエディター
|
||||
|
||||
|
||||
|
||||
|
||||
### コントリビューション
|
||||
|
||||
コントリビューションを歓迎します!コミット規約、開発ワークフロー、リリースプロセスの詳細は [Contributing Guide](CONTRIBUTING.md) をご覧ください。
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### よくある問題と解決方法
|
||||
|
||||
|
||||
#### 「Claude プロジェクトが見つかりません」
|
||||
**問題**: UI にプロジェクトが表示されない、またはプロジェクトリストが空
|
||||
**解決方法**:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) が正しくインストールされていることを確認
|
||||
- 少なくとも1つのプロジェクトディレクトリで `claude` コマンドを実行して初期化
|
||||
- `~/.claude/projects/` ディレクトリが存在し、適切な権限があることを確認
|
||||
|
||||
#### ファイルエクスプローラーの問題
|
||||
**問題**: ファイルが読み込まれない、権限エラー、空のディレクトリ
|
||||
**解決方法**:
|
||||
- プロジェクトディレクトリの権限を確認(ターミナルで `ls -la`)
|
||||
- プロジェクトパスが存在しアクセス可能であることを確認
|
||||
- 詳細なエラーメッセージについてはサーバーコンソールログを確認
|
||||
- プロジェクト範囲外のシステムディレクトリにアクセスしていないことを確認
|
||||
|
||||
|
||||
## ライセンス
|
||||
|
||||
GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルをご覧ください。
|
||||
|
||||
このプロジェクトはオープンソースであり、GPL v3 ライセンスの下で自由に使用、変更、配布できます。
|
||||
|
||||
## 謝辞
|
||||
|
||||
### 使用技術
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - ユーザーインターフェースライブラリ
|
||||
- **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファースト CSS フレームワーク
|
||||
- **[CodeMirror](https://codemirror.net/)** - 高度なコードエディター
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(オプション)* - AI 駆動のプロジェクト管理とタスク計画
|
||||
|
||||
## サポートとコミュニティ
|
||||
|
||||
### 最新情報を入手
|
||||
- このリポジトリに **Star** をつけてサポートを表明
|
||||
- **Watch** で更新や新リリースを確認
|
||||
- プロジェクトを **Follow** してお知らせを受け取る
|
||||
|
||||
### スポンサー
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。</strong>
|
||||
</div>
|
||||
345
README.ko.md
Normal file
@@ -0,0 +1,345 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (일명 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## 스크린샷
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>데스크톱 뷰</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>프로젝트 개요와 채팅을 보여주는 메인 인터페이스</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>모바일 경험</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>터치 내비게이션이 포함된 반응형 모바일 디자인</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 선택</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Claude Code, Cursor CLI, Codex 중 선택</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 기능
|
||||
|
||||
- **반응형 디자인** - 데스크톱, 태블릿, 모바일에서 원활하게 작동하여 모바일에서도 Claude Code, Cursor 또는 Codex를 사용할 수 있습니다
|
||||
- **대화형 채팅 인터페이스** - Claude Code, Cursor 또는 Codex와 원활하게 소통하는 내장 채팅 인터페이스
|
||||
- **통합 셸 터미널** - 내장 셸 기능을 통한 Claude Code, Cursor CLI 또는 Codex 직접 접근
|
||||
- **파일 탐색기** - 구문 강조 및 실시간 편집이 가능한 대화형 파일 트리
|
||||
- **Git 탐색기** - 변경사항 보기, 스테이징 및 커밋. 브랜치 전환도 가능
|
||||
- **세션 관리** - 대화 재개, 여러 세션 관리 및 기록 추적
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 기반 작업 계획, PRD 분석 및 워크플로우 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude Sonnet 4.5, Opus 4.5 및 GPT-5.2 지원
|
||||
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 사전 요구사항
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 이상
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) 설치 및 구성, 그리고/또는
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) 설치 및 구성, 그리고/또는
|
||||
- [Codex](https://developers.openai.com/codex) 설치 및 구성
|
||||
|
||||
### 원클릭 실행 (권장)
|
||||
|
||||
설치 없이 바로 실행:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
서버가 시작되면 `http://localhost:3001` (또는 설정한 PORT)에서 접근할 수 있습니다.
|
||||
|
||||
**재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다
|
||||
### 전역 설치 (정기적 사용 시)
|
||||
|
||||
자주 사용하는 경우 한 번만 전역 설치:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
간단한 명령으로 시작:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**재시작**: Ctrl+C로 중지한 후 `claude-code-ui`를 다시 실행합니다.
|
||||
|
||||
**업데이트**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI 사용법
|
||||
|
||||
전역 설치 후 `claude-code-ui`와 `cloudcli` 명령을 사용할 수 있습니다:
|
||||
|
||||
| 명령 / 옵션 | 약어 | 설명 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` 또는 `claude-code-ui` | | 서버 시작 (기본값) |
|
||||
| `cloudcli start` | | 서버 명시적 시작 |
|
||||
| `cloudcli status` | | 구성 및 데이터 위치 표시 |
|
||||
| `cloudcli update` | | 최신 버전으로 업데이트 |
|
||||
| `cloudcli help` | | 도움말 정보 표시 |
|
||||
| `cloudcli version` | | 버전 정보 표시 |
|
||||
| `--port <port>` | `-p` | 서버 포트 설정 (기본값: 3001) |
|
||||
| `--database-path <path>` | | 사용자 지정 데이터베이스 위치 설정 |
|
||||
|
||||
**예시:**
|
||||
```bash
|
||||
cloudcli # 기본 설정으로 시작
|
||||
cloudcli -p 8080 # 사용자 지정 포트로 시작
|
||||
cloudcli status # 현재 구성 표시
|
||||
```
|
||||
|
||||
### 백그라운드 서비스로 실행 (프로덕션 권장)
|
||||
|
||||
프로덕션 환경에서는 PM2(Process Manager 2)를 사용하여 Claude Code UI를 백그라운드 서비스로 실행하세요:
|
||||
|
||||
#### PM2 설치
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### 백그라운드 서비스로 시작
|
||||
|
||||
```bash
|
||||
# 백그라운드에서 서버 시작
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# 또는 짧은 별칭 사용
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# 사용자 지정 포트로 시작
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### 시스템 부팅 시 자동 시작
|
||||
|
||||
시스템 부팅 시 Claude Code UI를 자동으로 시작하려면:
|
||||
|
||||
```bash
|
||||
# 플랫폼에 맞는 시작 스크립트 생성
|
||||
pm2 startup
|
||||
|
||||
# 현재 프로세스 목록 저장
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### 로컬 개발 설치
|
||||
|
||||
1. **리포지토리 클론:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **의존성 설치:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **환경 구성:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 원하는 설정으로 .env 파일 편집
|
||||
```
|
||||
|
||||
4. **애플리케이션 시작:**
|
||||
```bash
|
||||
# 개발 모드 (핫 리로드 포함)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
애플리케이션은 .env에서 지정한 포트에서 시작됩니다
|
||||
|
||||
5. **브라우저 열기:**
|
||||
- 개발: `http://localhost:3001`
|
||||
|
||||
## 보안 및 도구 설정
|
||||
|
||||
**🔒 중요 공지**: 모든 Claude Code 도구는 **기본적으로 비활성화**되어 있습니다. 이는 잠재적으로 유해한 작업이 자동으로 실행되는 것을 방지합니다.
|
||||
|
||||
### 도구 활성화
|
||||
|
||||
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
|
||||
|
||||
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
|
||||
3. **선택적으로 활성화** - 필요한 도구만 활성화
|
||||
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*도구 설정 인터페이스 - 필요한 것만 활성화하세요*
|
||||
|
||||
</div>
|
||||
|
||||
**권장 접근법**: 기본 도구부터 활성화하고 필요에 따라 추가하세요. 언제든지 이 설정을 조정할 수 있습니다.
|
||||
|
||||
## TaskMaster AI 통합 *(선택사항)*
|
||||
|
||||
Claude Code UI는 고급 프로젝트 관리 및 AI 기반 작업 계획을 위한 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(일명 claude-task-master) 통합을 지원합니다.
|
||||
|
||||
제공 기능
|
||||
- PRD(제품 요구사항 문서)에서 AI 기반 작업 생성
|
||||
- 스마트 작업 분해 및 의존성 관리
|
||||
- 시각적 작업 보드 및 진행 상황 추적
|
||||
|
||||
**설정 및 문서**: 설치 지침, 구성 가이드 및 사용 예시는 [TaskMaster AI GitHub 리포지토리](https://github.com/eyaltoledano/claude-task-master)를 방문하세요.
|
||||
설치 후 설정에서 활성화할 수 있습니다
|
||||
|
||||
|
||||
## 사용 가이드
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
#### 프로젝트 관리
|
||||
Claude Code, Cursor 또는 Codex 세션을 사용할 수 있을 때 자동으로 발견하고 프로젝트로 그룹화합니다
|
||||
- **프로젝트 작업** - 프로젝트 이름 변경, 삭제 및 정리
|
||||
- **스마트 내비게이션** - 최근 프로젝트 및 세션에 빠르게 접근
|
||||
- **MCP 지원** - UI를 통해 자체 MCP 서버 추가
|
||||
|
||||
#### 채팅 인터페이스
|
||||
- **반응형 채팅 또는 Claude Code/Cursor CLI/Codex CLI 사용** - 적응형 채팅 인터페이스를 사용하거나 셸 버튼을 사용하여 선택한 CLI에 연결할 수 있습니다
|
||||
- **실시간 통신** - WebSocket 연결을 통해 선택한 CLI(Claude Code/Cursor/Codex)에서 응답 스트리밍
|
||||
- **세션 관리** - 이전 대화 재개 또는 새 세션 시작
|
||||
- **메시지 기록** - 타임스탬프 및 메타데이터가 포함된 전체 대화 기록
|
||||
- **다중 형식 지원** - 텍스트, 코드 블록 및 파일 참조
|
||||
|
||||
#### 파일 탐색기 및 편집기
|
||||
- **대화형 파일 트리** - 확장/축소 내비게이션으로 프로젝트 구조 탐색
|
||||
- **실시간 파일 편집** - 인터페이스에서 직접 파일 읽기, 수정 및 저장
|
||||
- **구문 강조** - 다양한 프로그래밍 언어 지원
|
||||
- **파일 작업** - 파일 및 디렉토리 생성, 이름 변경, 삭제
|
||||
|
||||
#### Git 탐색기
|
||||
|
||||
|
||||
#### TaskMaster AI 통합 *(선택사항)*
|
||||
- **시각적 작업 보드** - 개발 작업 관리를 위한 칸반 스타일 인터페이스
|
||||
- **PRD 파서** - 제품 요구사항 문서를 생성하고 구조화된 작업으로 변환
|
||||
- **진행 상황 추적** - 실시간 상태 업데이트 및 완료 추적
|
||||
|
||||
#### 세션 관리
|
||||
- **세션 지속성** - 모든 대화 자동 저장
|
||||
- **세션 정리** - 프로젝트 및 타임스탬프별 세션 그룹화
|
||||
- **세션 작업** - 대화 기록 이름 변경, 삭제 및 내보내기
|
||||
- **크로스 디바이스 동기화** - 모든 기기에서 세션 접근
|
||||
|
||||
### 모바일 앱
|
||||
- **반응형 디자인** - 모든 화면 크기에 최적화
|
||||
- **터치 친화적 인터페이스** - 스와이프 제스처 및 터치 내비게이션
|
||||
- **모바일 내비게이션** - 엄지 내비게이션을 위한 하단 탭 바
|
||||
- **적응형 레이아웃** - 접을 수 있는 사이드바 및 스마트 콘텐츠 우선순위
|
||||
- **홈 화면 바로가기 추가** - 홈 화면에 바로가기를 추가하면 앱이 PWA처럼 작동합니다
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 시스템 개요
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 백엔드 (Node.js + Express)
|
||||
- **Express 서버** - 정적 파일 제공이 포함된 RESTful API
|
||||
- **WebSocket 서버** - 채팅 및 프로젝트 새로고침을 위한 통신
|
||||
- **에이전트 통합 (Claude Code / Cursor CLI / Codex)** - 프로세스 생성 및 관리
|
||||
- **파일 시스템 API** - 프로젝트를 위한 파일 브라우저 노출
|
||||
|
||||
### 프론트엔드 (React + Vite)
|
||||
- **React 18** - hooks를 사용한 현대적 컴포넌트 아키텍처
|
||||
- **CodeMirror** - 구문 강조를 지원하는 고급 코드 편집기
|
||||
|
||||
|
||||
|
||||
### 기여하기
|
||||
|
||||
기여를 환영합니다! 커밋 규칙, 개발 워크플로우, 릴리스 프로세스에 대한 자세한 내용은 [Contributing Guide](CONTRIBUTING.md)를 참조해주세요.
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 일반적인 문제 및 해결 방법
|
||||
|
||||
|
||||
#### "Claude 프로젝트를 찾을 수 없음"
|
||||
**문제**: UI에 프로젝트가 없거나 프로젝트 목록이 비어 있음
|
||||
**해결 방법**:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)가 올바르게 설치되었는지 확인
|
||||
- 초기화를 위해 최소 하나의 프로젝트 디렉토리에서 `claude` 명령 실행
|
||||
- `~/.claude/projects/` 디렉토리가 존재하고 적절한 권한이 있는지 확인
|
||||
|
||||
#### 파일 탐색기 문제
|
||||
**문제**: 파일이 로드되지 않음, 권한 오류, 빈 디렉토리
|
||||
**해결 방법**:
|
||||
- 프로젝트 디렉토리 권한 확인 (터미널에서 `ls -la`)
|
||||
- 프로젝트 경로가 존재하고 접근 가능한지 확인
|
||||
- 자세한 오류 메시지는 서버 콘솔 로그 검토
|
||||
- 프로젝트 범위 밖의 시스템 디렉토리에 접근하지 않는지 확인
|
||||
|
||||
|
||||
## 라이선스
|
||||
|
||||
GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
|
||||
|
||||
이 프로젝트는 오픈 소스이며 GPL v3 라이선스에 따라 자유롭게 사용, 수정 및 배포할 수 있습니다.
|
||||
|
||||
## 감사의 말
|
||||
|
||||
### 사용 기술
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic의 공식 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor의 공식 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리
|
||||
- **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크
|
||||
- **[CodeMirror](https://codemirror.net/)** - 고급 코드 편집기
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(선택사항)* - AI 기반 프로젝트 관리 및 작업 계획
|
||||
|
||||
## 지원 및 커뮤니티
|
||||
|
||||
### 최신 정보 받기
|
||||
- 이 리포지토리에 **Star**를 눌러 지지를 표시하세요
|
||||
- **Watch**로 업데이트 및 새 릴리스를 확인하세요
|
||||
- 프로젝트를 **Follow**하여 공지사항을 받으세요
|
||||
|
||||
### 스폰서
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Claude Code, Cursor 및 Codex 커뮤니티를 위해 정성껏 만들었습니다.</strong>
|
||||
</div>
|
||||
154
README.md
@@ -1,10 +1,13 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Claude Code UI</h1>
|
||||
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and [Cursor CLI](https://docs.cursor.com/en/cli/overview). You can use it locally or remotely to view your active projects and sessions in Claude Code or Cursor and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. Supports models including **Claude Sonnet 4**, **Opus 4.1**, and **GPT-5**
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,7 +33,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
<h3>CLI Selection</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Select between Claude Code and Cursor CLI</em>
|
||||
<em>Select between Claude Code, Cursor CLI and Codex</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -41,23 +44,24 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
## Features
|
||||
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code or Cursor
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code or Cursor CLI through built-in shell functionality
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4, Opus 4.1, and GPT-5
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 or higher
|
||||
- [Node.js](https://nodejs.org/) v22 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured
|
||||
|
||||
### One-click Operation (Recommended)
|
||||
|
||||
@@ -67,7 +71,89 @@ No installation required, direct operation:
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
Your default browser will automatically open the Claude Code UI interface.
|
||||
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
|
||||
### Global Installation (For Regular Use)
|
||||
|
||||
For frequent use, install globally once:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
Then start with a simple command:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
|
||||
|
||||
**To update**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
|
||||
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
|
||||
|
||||
| Command / Option | Short | Description |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` or `claude-code-ui` | | Start the server (default) |
|
||||
| `cloudcli start` | | Start the server explicitly |
|
||||
| `cloudcli status` | | Show configuration and data locations |
|
||||
| `cloudcli update` | | Update to the latest version |
|
||||
| `cloudcli help` | | Show help information |
|
||||
| `cloudcli version` | | Show version information |
|
||||
| `--port <port>` | `-p` | Set server port (default: 3001) |
|
||||
| `--database-path <path>` | | Set custom database location |
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
cloudcli # Start with defaults
|
||||
cloudcli -p 8080 # Start on custom port
|
||||
cloudcli status # Show current configuration
|
||||
```
|
||||
|
||||
### 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"
|
||||
|
||||
# Start on a custom port
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -138,15 +224,15 @@ After installing it you should be able to enable it from the Settings
|
||||
### Core Features
|
||||
|
||||
#### Project Management
|
||||
The UI automatically discovers Claude Code projects from `~/.claude/projects/` and provides:
|
||||
- **Visual Project Browser** - All available projects with metadata and session counts
|
||||
It automatically discovers Claude Code, Cursor or Codex sessions when available and groups them together into projects
|
||||
session counts
|
||||
- **Project Actions** - Rename, delete, and organize projects
|
||||
- **Smart Navigation** - Quick access to recent projects and sessions
|
||||
- **MCP support** - Add your own MCP servers through the UI
|
||||
|
||||
#### Chat Interface
|
||||
- **Use responsive chat or Claude Code/Cursor CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI.
|
||||
- **Real-time Communication** - Stream responses from Claude with WebSocket connection
|
||||
- **Use responsive chat or Claude Code/Cursor CLI/Codex CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI.
|
||||
- **Real-time Communication** - Stream responses from your selected CLI (Claude Code/Cursor/Codex) with WebSocket connection
|
||||
- **Session Management** - Resume previous conversations or start fresh sessions
|
||||
- **Message History** - Complete conversation history with timestamps and metadata
|
||||
- **Multi-format Support** - Text, code blocks, and file references
|
||||
@@ -184,16 +270,16 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Claude CLI │
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Backend (Node.js + Express)
|
||||
- **Express Server** - RESTful API with static file serving
|
||||
- **WebSocket Server** - Communication for chats and project refresh
|
||||
- **CLI Integration (Claude Code / Cursor)** - Process spawning and management
|
||||
- **Session Management** - JSONL parsing and conversation persistence
|
||||
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
|
||||
- **File System API** - Exposing file browser for projects
|
||||
|
||||
### Frontend (React + Vite)
|
||||
@@ -206,43 +292,19 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Please follow these guidelines:
|
||||
|
||||
#### Getting Started
|
||||
1. **Fork** the repository
|
||||
2. **Clone** your fork: `git clone <your-fork-url>`
|
||||
3. **Install** dependencies: `npm install`
|
||||
4. **Create** a feature branch: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### Development Process
|
||||
1. **Make your changes** following the existing code style
|
||||
2. **Test thoroughly** - ensure all features work correctly
|
||||
3. **Run quality checks**: `npm run lint && npm run format`
|
||||
4. **Commit** with descriptive messages following [Conventional Commits](https://conventionalcommits.org/)
|
||||
5. **Push** to your branch: `git push origin feature/amazing-feature`
|
||||
6. **Submit** a Pull Request with:
|
||||
- Clear description of changes
|
||||
- Screenshots for UI changes
|
||||
- Test results if applicable
|
||||
|
||||
#### What to Contribute
|
||||
- **Bug fixes** - Help us improve stability
|
||||
- **New features** - Enhance functionality (discuss in issues first)
|
||||
- **Documentation** - Improve guides and API docs
|
||||
- **UI/UX improvements** - Better user experience
|
||||
- **Performance optimizations** - Make it faster
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on commit conventions, development workflow, and release process.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues & Solutions
|
||||
|
||||
|
||||
#### "No Claude projects found"
|
||||
**Problem**: The UI shows no projects or empty project list
|
||||
**Solutions**:
|
||||
- Ensure [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) is properly installed
|
||||
- Ensure [Claude Code](https://docs.anthropic.com/en/docs/claude-code) is properly installed
|
||||
- Run `claude` command in at least one project directory to initialize
|
||||
- Verify `~/.claude/projects/` directory exists and has proper permissions
|
||||
d
|
||||
|
||||
#### File Explorer Issues
|
||||
**Problem**: Files not loading, permission errors, empty directories
|
||||
@@ -263,6 +325,8 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
|
||||
### Built With
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - User interface library
|
||||
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
@@ -281,5 +345,5 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Made with care for the Claude Code community.</strong>
|
||||
<strong>Made with care for the Claude Code, Cursor and Codex community.</strong>
|
||||
</div>
|
||||
|
||||
347
README.zh-CN.md
Normal file
@@ -0,0 +1,347 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (又名 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## 截图
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>桌面视图</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>显示项目概览和聊天界面的主界面</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>移动端体验</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>具有触摸导航的响应式移动设计</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 选择</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>在 Claude Code、Cursor CLI 和 Codex 之间选择</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **响应式设计** - 在桌面、平板和移动设备上无缝运行,您也可以在移动端使用 Claude Code、Cursor 或 Codex
|
||||
- **交互式聊天界面** - 内置聊天界面,与 Claude Code、Cursor 或 Codex 无缝通信
|
||||
- **集成 Shell 终端** - 通过内置 shell 功能直接访问 Claude Code、Cursor CLI 或 Codex
|
||||
- **文件浏览器** - 交互式文件树,支持语法高亮和实时编辑
|
||||
- **Git 浏览器** - 查看、暂存和提交您的更改。您还可以切换分支
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **TaskMaster AI 集成** *(可选)* - 通过 AI 驱动的任务规划、PRD 解析和工作流自动化实现高级项目管理
|
||||
- **模型兼容性** - 适用于 Claude Sonnet 4.5、Opus 4.5 和 GPT-5.2
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 或更高版本
|
||||
- 已安装并配置 [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code),和/或
|
||||
- 已安装并配置 [Cursor CLI](https://docs.cursor.com/en/cli/overview),和/或
|
||||
- 已安装并配置 [Codex](https://developers.openai.com/codex)
|
||||
|
||||
### 一键操作(推荐)
|
||||
|
||||
无需安装,直接运行:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
服务器将启动并可通过 `http://localhost:3001`(或您配置的 PORT)访问。
|
||||
|
||||
**重启**: 停止服务器后只需再次运行相同的 `npx` 命令
|
||||
|
||||
### 全局安装(供常规使用)
|
||||
|
||||
为了频繁使用,一次性全局安装:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
然后使用简单命令启动:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**重启**: 使用 Ctrl+C 停止,然后再次运行 `claude-code-ui`。
|
||||
|
||||
**更新**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI 使用方法
|
||||
|
||||
全局安装后,您可以访问 `claude-code-ui` 和 `cloudcli` 命令:
|
||||
|
||||
| 命令 / 选项 | 简写 | 描述 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` 或 `claude-code-ui` | | 启动服务器(默认) |
|
||||
| `cloudcli start` | | 显式启动服务器 |
|
||||
| `cloudcli status` | | 显示配置和数据位置 |
|
||||
| `cloudcli update` | | 更新到最新版本 |
|
||||
| `cloudcli help` | | 显示帮助信息 |
|
||||
| `cloudcli version` | | 显示版本信息 |
|
||||
| `--port <port>` | `-p` | 设置服务器端口(默认: 3001) |
|
||||
| `--database-path <path>` | | 设置自定义数据库位置 |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
cloudcli # 使用默认设置启动
|
||||
cloudcli -p 8080 # 在自定义端口启动
|
||||
cloudcli status # 显示当前配置
|
||||
```
|
||||
|
||||
### 作为后台服务运行(推荐用于生产环境)
|
||||
|
||||
在生产环境中,使用 PM2(Process Manager 2)将 Claude Code UI 作为后台服务运行:
|
||||
|
||||
#### 安装 PM2
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### 作为后台服务启动
|
||||
|
||||
```bash
|
||||
# 在后台启动服务器
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# 或使用更短的别名
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# 在自定义端口启动
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### 系统启动时自动启动
|
||||
|
||||
要使 Claude Code UI 在系统启动时自动启动:
|
||||
|
||||
```bash
|
||||
# 为您的平台生成启动脚本
|
||||
pm2 startup
|
||||
|
||||
# 保存当前进程列表
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### 本地开发安装
|
||||
|
||||
1. **克隆仓库:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **安装依赖:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **配置环境:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 使用您喜欢的设置编辑 .env
|
||||
```
|
||||
|
||||
4. **启动应用程序:**
|
||||
```bash
|
||||
# 开发模式(支持热重载)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
应用程序将在您在 .env 中指定的端口启动
|
||||
|
||||
5. **打开浏览器:**
|
||||
- 开发环境: `http://localhost:3001`
|
||||
|
||||
## 安全与工具配置
|
||||
|
||||
**🔒 重要提示**: 所有 Claude Code 工具**默认禁用**。这可以防止潜在的有害操作自动运行。
|
||||
|
||||
### 启用工具
|
||||
|
||||
要使用 Claude Code 的完整功能,您需要手动启用工具:
|
||||
|
||||
1. **打开工具设置** - 点击侧边栏中的齿轮图标
|
||||
3. **选择性启用** - 仅打开您需要的工具
|
||||
4. **应用设置** - 您的偏好设置将保存在本地
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*工具设置界面 - 仅启用您需要的内容*
|
||||
|
||||
</div>
|
||||
|
||||
**推荐方法**: 首先启用基本工具,然后根据需要添加更多。您可以随时调整这些设置。
|
||||
|
||||
## TaskMaster AI 集成 *(可选)*
|
||||
|
||||
Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(aka claude-task-master)集成,用于高级项目管理和 AI 驱动的任务规划。
|
||||
|
||||
它提供
|
||||
- 从 PRD(产品需求文档)生成 AI 驱动的任务
|
||||
- 智能任务分解和依赖管理
|
||||
- 可视化任务板和进度跟踪
|
||||
|
||||
**设置与文档**: 访问 [TaskMaster AI GitHub 仓库](https://github.com/eyaltoledano/claude-task-master)获取安装说明、配置指南和使用示例。
|
||||
安装后,您应该能够从设置中启用它
|
||||
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 核心功能
|
||||
|
||||
#### 项目管理
|
||||
当可用时,它会自动发现 Claude Code、Cursor 或 Codex 会话并将它们分组到项目中
|
||||
- **项目操作** - 重命名、删除和组织项目
|
||||
- **智能导航** - 快速访问最近的项目和会话
|
||||
- **MCP 支持** - 通过 UI 添加您自己的 MCP 服务器
|
||||
|
||||
#### 聊天界面
|
||||
- **使用响应式聊天或 Claude Code/Cursor CLI/Codex CLI** - 您可以使用自适应聊天界面或使用 shell 按钮连接到您选择的 CLI
|
||||
- **实时通信** - 通过 WebSocket 连接从您选择的 CLI(Claude Code/Cursor/Codex)流式传输响应
|
||||
- **会话管理** - 恢复之前的对话或启动新会话
|
||||
- **消息历史** - 带有时间戳和元数据的完整对话历史
|
||||
- **多格式支持** - 文本、代码块和文件引用
|
||||
|
||||
#### 文件浏览器与编辑器
|
||||
- **交互式文件树** - 使用展开/折叠导航浏览项目结构
|
||||
- **实时文件编辑** - 直接在界面中读取、修改和保存文件
|
||||
- **语法高亮** - 支持多种编程语言
|
||||
- **文件操作** - 创建、重命名、删除文件和目录
|
||||
|
||||
#### Git 浏览器
|
||||
|
||||
|
||||
#### TaskMaster AI 集成 *(可选)*
|
||||
- **可视化任务板** - 用于管理开发任务的看板风格界面
|
||||
- **PRD 解析器** - 创建产品需求文档并将其解析为结构化任务
|
||||
- **进度跟踪** - 实时状态更新和完成跟踪
|
||||
|
||||
#### 会话管理
|
||||
- **会话持久化** - 所有对话自动保存
|
||||
- **会话组织** - 按项目和 timestamp 分组会话
|
||||
- **会话操作** - 重命名、删除和导出对话历史
|
||||
- **跨设备同步** - 从任何设备访问会话
|
||||
|
||||
### 移动应用
|
||||
- **响应式设计** - 针对所有屏幕尺寸进行优化
|
||||
- **触摸友好界面** - 滑动手势和触摸导航
|
||||
- **移动导航** - 底部选项卡栏,方便拇指导航
|
||||
- **自适应布局** - 可折叠侧边栏和智能内容优先级
|
||||
- **添加到主屏幕快捷方式** - 添加快捷方式到主屏幕,应用程序将像 PWA 一样运行
|
||||
|
||||
## 架构
|
||||
|
||||
### 系统概览
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 后端 (Node.js + Express)
|
||||
- **Express 服务器** - 具有静态文件服务的 RESTful API
|
||||
- **WebSocket 服务器** - 用于聊天和项目刷新的通信
|
||||
- **Agent 集成 (Claude Code / Cursor CLI / Codex)** - 进程生成和管理
|
||||
- **文件系统 API** - 为项目公开文件浏览器
|
||||
|
||||
### 前端 (React + Vite)
|
||||
- **React 18** - 带有 hooks 的现代组件架构
|
||||
- **CodeMirror** - 具有语法高亮的高级代码编辑器
|
||||
|
||||
|
||||
|
||||
|
||||
### 贡献
|
||||
|
||||
我们欢迎贡献!有关提交规范、开发流程和发布流程的详细信息,请参阅 [Contributing Guide](CONTRIBUTING.md)。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题与解决方案
|
||||
|
||||
|
||||
#### "未找到 Claude 项目"
|
||||
**问题**: UI 显示没有项目或项目列表为空
|
||||
**解决方案**:
|
||||
- 确保已正确安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- 至少在一个项目目录中运行 `claude` 命令以进行初始化
|
||||
- 验证 `~/.claude/projects/` 目录存在并具有适当的权限
|
||||
|
||||
#### 文件浏览器问题
|
||||
**问题**: 文件无法加载、权限错误、空目录
|
||||
**解决方案**:
|
||||
- 检查项目目录权限(在终端中使用 `ls -la`)
|
||||
- 验证项目路径存在且可访问
|
||||
- 查看服务器控制台日志以获取详细错误消息
|
||||
- 确保您未尝试访问项目范围之外的系统目录
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
本项目是开源的,在 GPL v3 许可下可自由使用、修改和分发。
|
||||
|
||||
## 致谢
|
||||
|
||||
### 构建工具
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 的官方 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 的官方 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - 用户界面库
|
||||
- **[Vite](https://vitejs.dev/)** - 快速构建工具和开发服务器
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - 实用优先的 CSS 框架
|
||||
- **[CodeMirror](https://codemirror.net/)** - 高级代码编辑器
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(可选)* - AI 驱动的项目管理和任务规划
|
||||
|
||||
## 支持与社区
|
||||
|
||||
### 保持更新
|
||||
- **Star** 此仓库以表示支持
|
||||
- **Watch** 以获取更新和新版本
|
||||
- **Follow** 项目以获取公告
|
||||
|
||||
### 赞助商
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
|
||||
</div>
|
||||
@@ -5,13 +5,13 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Claude Code UI</title>
|
||||
<title>CloudCLI UI</title>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- iOS Safari PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Claude UI" />
|
||||
|
||||
|
||||
1840
package-lock.json
generated
47
package.json
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.9.0",
|
||||
"version": "1.20.1",
|
||||
"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/",
|
||||
"shared/",
|
||||
"dist/",
|
||||
"scripts/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"homepage": "https://cloudcli.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
@@ -26,31 +29,42 @@
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "release-it"
|
||||
"release": "./release.sh",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/fix-node-pty.js"
|
||||
},
|
||||
"keywords": [
|
||||
"claude coode",
|
||||
"claude code",
|
||||
"ai",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/merge": "^6.11.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@openai/codex-sdk": "^0.101.0",
|
||||
"@replit/codemirror-minimap": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -61,7 +75,10 @@
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
@@ -70,16 +87,23 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"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",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0"
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
@@ -91,6 +115,7 @@
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
879
public/api-docs.html
Normal file
@@ -0,0 +1,879 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Code UI - API Documentation</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
|
||||
<!-- Prism.js for syntax highlighting -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-600: #4b5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--gray-900: #111827;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--green: #10b981;
|
||||
--red: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-900);
|
||||
background: var(--gray-50);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
padding: 1.5rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--primary);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.brand-text .subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: white;
|
||||
border-right: 1px solid var(--gray-200);
|
||||
padding: 2rem 0;
|
||||
position: sticky;
|
||||
top: 73px;
|
||||
height: calc(100vh - 73px);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-600);
|
||||
padding: 0 1.5rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
display: block;
|
||||
padding: 0.625rem 1.5rem;
|
||||
color: var(--gray-700);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar a:hover {
|
||||
background: var(--gray-50);
|
||||
color: var(--primary);
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 73px);
|
||||
}
|
||||
|
||||
.section-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 600px;
|
||||
}
|
||||
|
||||
.docs-section {
|
||||
padding: 3rem 3rem;
|
||||
background: white;
|
||||
border-right: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.examples-section {
|
||||
padding: 3rem 2rem;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.examples-section h4 {
|
||||
color: #e6edf3;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
margin: 2.5rem 0 1rem;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.intro {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%);
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
color: var(--gray-700);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: var(--gray-50);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.method {
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.method-post {
|
||||
background: var(--green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.875rem;
|
||||
background: var(--gray-100);
|
||||
border: 1px solid var(--gray-200);
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.875rem;
|
||||
border: 1px solid var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.875em;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.api-url {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1875rem 0.625rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: var(--red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 1.25rem;
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
border-left: 4px solid var(--primary);
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Code tabs in side panel */
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #30363d;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #7d8590;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #e6edf3;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #e6edf3;
|
||||
background: #1f6feb;
|
||||
border-color: #1f6feb;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
pre[class*="language-"] {
|
||||
margin: 0 0 1.5rem 0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.example-block {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.section-row {
|
||||
grid-template-columns: 1fr 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.section-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.examples-section {
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.docs-section {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.examples-section {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1>Claude Code UI</h1>
|
||||
<div class="subtitle">API Documentation</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" class="back-link">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to App
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-title">Getting Started</div>
|
||||
<a href="#authentication">Authentication</a>
|
||||
<a href="#credentials">GitHub Credentials</a>
|
||||
|
||||
<div class="sidebar-title">API Reference</div>
|
||||
<a href="#agent">Agent</a>
|
||||
|
||||
<div class="sidebar-title">Examples</div>
|
||||
<a href="#usage-examples">Usage Patterns</a>
|
||||
</nav>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Intro Section -->
|
||||
<div class="section-row">
|
||||
<div class="docs-section">
|
||||
<div class="intro">
|
||||
<p><strong>Programmatically trigger AI agents to work on projects.</strong> Clone GitHub repositories or use existing project paths. Perfect for CI/CD pipelines, automated code reviews, and bulk processing.</p>
|
||||
</div>
|
||||
|
||||
<section id="authentication">
|
||||
<h2>Authentication</h2>
|
||||
<p>All API requests require authentication using an API key in the <code>X-API-Key</code> header.</p>
|
||||
|
||||
<p>Generate API keys in Settings → API & Tokens.</p>
|
||||
</section>
|
||||
|
||||
<section id="credentials">
|
||||
<h3>GitHub Credentials</h3>
|
||||
<p>For private repositories, store a GitHub token in settings or pass it with each request.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> GitHub tokens in the request override stored tokens.
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="examples-section">
|
||||
<div class="example-block">
|
||||
<h4>Authentication Header</h4>
|
||||
<pre><code class="language-http">X-API-Key: ck_your_api_key_here</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent API Section -->
|
||||
<div class="section-row">
|
||||
<div class="docs-section">
|
||||
<section id="agent">
|
||||
<h2>Agent</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="endpoint-header">
|
||||
<span class="method method-post">POST</span>
|
||||
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
|
||||
</div>
|
||||
|
||||
<p>Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.</p>
|
||||
|
||||
<h4>Request Body Parameters</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>githubUrl</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Conditional</span></td>
|
||||
<td>GitHub repository URL to clone. If path exists with same repo, reuses it. If path exists with different repo, returns error.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>projectPath</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Conditional</span></td>
|
||||
<td>Path to existing project OR destination for cloning. If omitted with <code>githubUrl</code>, auto-generates path. If used alone, must point to existing project directory.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>message</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-required">Required</span></td>
|
||||
<td>Task for the AI agent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>provider</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>stream</code></td>
|
||||
<td>boolean</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>Enable streaming (default: <code>true</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>model</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td id="model-options-cell">
|
||||
Model identifier for the AI provider (loading from constants...)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>cleanup</code></td>
|
||||
<td>boolean</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>Auto-cleanup after completion (default: <code>true</code>). Only applies when cloning via <code>githubUrl</code>. Existing projects specified via <code>projectPath</code> are never cleaned up.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>githubToken</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>GitHub token for private repos</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>branchName</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>Custom branch name to use. If provided, <code>createBranch</code> is automatically enabled. Branch names are validated against Git naming rules. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>createBranch</code></td>
|
||||
<td>boolean</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>Create a new branch after successful completion (default: <code>false</code>). Automatically set to <code>true</code> if <code>branchName</code> is provided. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>createPR</code></td>
|
||||
<td>boolean</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>Create a pull request after successful completion (default: <code>false</code>). PR title and description auto-generated from commit messages. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="note">
|
||||
<strong>Path Handling Behavior:</strong><br><br>
|
||||
<strong>Scenario 1:</strong> Only <code>githubUrl</code> → Clones to auto-generated temporary path<br>
|
||||
<strong>Scenario 2:</strong> Only <code>projectPath</code> → Uses existing project at specified path<br>
|
||||
<strong>Scenario 3:</strong> Both provided → Clones <code>githubUrl</code> to <code>projectPath</code><br><br>
|
||||
<strong>Validation:</strong> If <code>projectPath</code> exists and contains a git repository, the remote URL is compared with <code>githubUrl</code>. If URLs match, the existing repo is reused. If URLs differ, an error is returned.
|
||||
</div>
|
||||
|
||||
<h4>Response (Streaming)</h4>
|
||||
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
|
||||
|
||||
<h4>Response (Non-Streaming)</h4>
|
||||
<p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>
|
||||
|
||||
<h4>Error Response</h4>
|
||||
<p>Returns error details with appropriate HTTP status code.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="examples-section">
|
||||
<div class="example-block">
|
||||
<h4>Basic Request</h4>
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" onclick="showTab('curl-basic')">cURL</button>
|
||||
<button class="tab-button" onclick="showTab('js-basic')">JavaScript</button>
|
||||
<button class="tab-button" onclick="showTab('python-basic')">Python</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="curl-basic">
|
||||
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ck_..." \
|
||||
-d '{
|
||||
"githubUrl": "https://github.com/user/repo",
|
||||
"message": "Add error handling to main.js"
|
||||
}'</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="js-basic">
|
||||
<pre><code class="language-javascript">const response = await fetch('<span class="api-url">http://localhost:3001</span>/api/agent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': process.env.CLAUDE_API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
githubUrl: 'https://github.com/user/repo',
|
||||
message: 'Add error handling',
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="python-basic">
|
||||
<pre><code class="language-python">import requests
|
||||
import os
|
||||
|
||||
response = requests.post(
|
||||
'<span class="api-url">http://localhost:3001</span>/api/agent',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': os.environ['CLAUDE_API_KEY']
|
||||
},
|
||||
json={
|
||||
'githubUrl': 'https://github.com/user/repo',
|
||||
'message': 'Add error handling',
|
||||
'stream': False
|
||||
}
|
||||
)
|
||||
|
||||
print(response.json())</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Streaming Response</h4>
|
||||
<pre><code class="language-javascript">data: {"type":"status","message":"Repository cloned"}
|
||||
data: {"type":"thinking","content":"Analyzing..."}
|
||||
data: {"type":"tool_use","tool":"read_file"}
|
||||
data: {"type":"content","content":"Done!"}
|
||||
data: {"type":"done"}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Non-Streaming Response</h4>
|
||||
<pre><code class="language-json">{
|
||||
"success": true,
|
||||
"sessionId": "abc123",
|
||||
"messages": [
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I've completed the task..."
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tokens": {
|
||||
"inputTokens": 150,
|
||||
"outputTokens": 50,
|
||||
"cacheReadTokens": 0,
|
||||
"cacheCreationTokens": 0,
|
||||
"totalTokens": 200
|
||||
},
|
||||
"projectPath": "/path/to/project",
|
||||
"branch": {
|
||||
"name": "fix-authentication-bug-abc123",
|
||||
"url": "https://github.com/user/repo/tree/fix-authentication-bug-abc123"
|
||||
},
|
||||
"pullRequest": {
|
||||
"number": 42,
|
||||
"url": "https://github.com/user/repo/pull/42"
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Error Response</h4>
|
||||
<pre><code class="language-json">{
|
||||
"success": false,
|
||||
"error": "Directory exists with different repo"
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Patterns Section -->
|
||||
<div class="section-row">
|
||||
<div class="docs-section">
|
||||
<section id="usage-examples">
|
||||
<h2>Usage Patterns</h2>
|
||||
|
||||
<h3>Clone and Process Repository</h3>
|
||||
<p>Clone a repository to an auto-generated temporary path and process it.</p>
|
||||
|
||||
<h3>Use Existing Project</h3>
|
||||
<p>Work with an existing project at a specific path.</p>
|
||||
|
||||
<h3>Clone to Specific Path</h3>
|
||||
<p>Clone a repository to a custom location for later reuse.</p>
|
||||
|
||||
<h3>CI/CD Integration</h3>
|
||||
<p>Integrate with GitHub Actions or other CI/CD pipelines.</p>
|
||||
|
||||
<h3>Create Branch and Pull Request</h3>
|
||||
<p>Automatically create a new branch and pull request after the agent completes its work. Branch names are auto-generated from the message, and PR title/description are auto-generated from commit messages.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="examples-section">
|
||||
<div class="example-block">
|
||||
<h4>Use Existing Project</h4>
|
||||
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ck_..." \
|
||||
-d '{
|
||||
"projectPath": "/home/user/my-project",
|
||||
"message": "Refactor database queries"
|
||||
}'</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Clone to Custom Path</h4>
|
||||
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ck_..." \
|
||||
-d '{
|
||||
"githubUrl": "https://github.com/user/repo",
|
||||
"projectPath": "/tmp/my-location",
|
||||
"message": "Review security",
|
||||
"cleanup": false
|
||||
}'</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>CI/CD (GitHub Actions)</h4>
|
||||
<pre><code class="language-yaml">- name: Trigger Agent
|
||||
run: |
|
||||
curl -X POST ${{ secrets.API_URL }}/api/agent \
|
||||
-H "X-API-Key: ${{ secrets.API_KEY }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"githubUrl": "${{ github.repository }}",
|
||||
"message": "Review for security",
|
||||
"githubToken": "${{ secrets.GITHUB_TOKEN }}"
|
||||
}'</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Create Branch and PR</h4>
|
||||
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ck_..." \
|
||||
-d '{
|
||||
"githubUrl": "https://github.com/user/repo",
|
||||
"message": "Fix authentication bug",
|
||||
"createBranch": true,
|
||||
"createPR": true,
|
||||
"stream": false
|
||||
}'</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Custom Branch Name</h4>
|
||||
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ck_..." \
|
||||
-d '{
|
||||
"githubUrl": "https://github.com/user/repo",
|
||||
"message": "Add user authentication",
|
||||
"branchName": "feature/user-auth",
|
||||
"createPR": true,
|
||||
"stream": false
|
||||
}'</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-block">
|
||||
<h4>Branch & PR Response</h4>
|
||||
<pre><code class="language-json">{
|
||||
"success": true,
|
||||
"branch": {
|
||||
"name": "feature/user-auth",
|
||||
"url": "https://github.com/user/repo/tree/feature/user-auth"
|
||||
},
|
||||
"pullRequest": {
|
||||
"number": 42,
|
||||
"url": "https://github.com/user/repo/pull/42"
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Import model constants
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
|
||||
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
document.querySelectorAll('.api-url').forEach(el => {
|
||||
el.textContent = apiUrl;
|
||||
});
|
||||
|
||||
// Dynamically populate model documentation
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
|
||||
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Tab switching
|
||||
window.showTab = function(tabName) {
|
||||
const parentBlock = event.target.closest('.example-block');
|
||||
if (!parentBlock) return;
|
||||
|
||||
parentBlock.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
parentBlock.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
const targetTab = parentBlock.querySelector('#' + tabName);
|
||||
if (targetTab) {
|
||||
targetTab.classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Prism.js -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-http.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3
public/icons/codex-white.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="100 100 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M304.246 295.411V249.828C304.246 245.989 305.687 243.109 309.044 241.191L400.692 188.412C413.167 181.215 428.042 177.858 443.394 177.858C500.971 177.858 537.44 222.482 537.44 269.982C537.44 273.34 537.44 277.179 536.959 281.018L441.954 225.358C436.197 222 430.437 222 424.68 225.358L304.246 295.411ZM518.245 472.945V364.024C518.245 357.304 515.364 352.507 509.608 349.149L389.174 279.096L428.519 256.543C431.877 254.626 434.757 254.626 438.115 256.543L529.762 309.323C556.154 324.679 573.905 357.304 573.905 388.971C573.905 425.436 552.315 459.024 518.245 472.941V472.945ZM275.937 376.982L236.592 353.952C233.235 352.034 231.794 349.154 231.794 345.315V239.756C231.794 188.416 271.139 149.548 324.4 149.548C344.555 149.548 363.264 156.268 379.102 168.262L284.578 222.964C278.822 226.321 275.942 231.119 275.942 237.838V376.986L275.937 376.982ZM360.626 425.922L304.246 394.255V327.083L360.626 295.416L417.002 327.083V394.255L360.626 425.922ZM396.852 571.789C376.698 571.789 357.989 565.07 342.151 553.075L436.674 498.374C442.431 495.017 445.311 490.219 445.311 483.499V344.352L485.138 367.382C488.495 369.299 489.936 372.179 489.936 376.018V481.577C489.936 532.917 450.109 571.785 396.852 571.785V571.789ZM283.134 464.79L191.486 412.01C165.094 396.654 147.343 364.029 147.343 332.362C147.343 295.416 169.415 262.309 203.48 248.393V357.791C203.48 364.51 206.361 369.308 212.117 372.665L332.074 442.237L292.729 464.79C289.372 466.707 286.491 466.707 283.134 464.79ZM277.859 543.48C223.639 543.48 183.813 502.695 183.813 452.314C183.813 448.475 184.294 444.636 184.771 440.797L279.295 495.498C285.051 498.856 290.812 498.856 296.568 495.498L417.002 425.927V471.509C417.002 475.349 415.562 478.229 412.204 480.146L320.557 532.926C308.081 540.122 293.206 543.48 277.854 543.48H277.859ZM396.852 600.576C454.911 600.576 503.37 559.313 514.41 504.612C568.149 490.696 602.696 440.315 602.696 388.976C602.696 355.387 588.303 322.762 562.392 299.25C564.791 289.173 566.231 279.096 566.231 269.024C566.231 200.411 510.571 149.067 446.274 149.067C433.322 149.067 420.846 150.984 408.37 155.305C386.775 134.192 357.026 120.758 324.4 120.758C266.342 120.758 217.883 162.02 206.843 216.721C153.104 230.637 118.557 281.018 118.557 332.357C118.557 365.946 132.95 398.571 158.861 422.083C156.462 432.16 155.022 442.237 155.022 452.309C155.022 520.922 210.682 572.266 274.978 572.266C287.931 572.266 300.407 570.349 312.883 566.028C334.473 587.141 364.222 600.576 396.852 600.576Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
3
public/icons/codex.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="100 100 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
12
public/icons/cursor-white.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edecec;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -1,19 +0,0 @@
|
||||
# PWA Icons Required
|
||||
|
||||
Create the following icon files in this directory:
|
||||
|
||||
- icon-72x72.png
|
||||
- icon-96x96.png
|
||||
- icon-128x128.png
|
||||
- icon-144x144.png
|
||||
- icon-152x152.png
|
||||
- icon-192x192.png
|
||||
- icon-384x384.png
|
||||
- icon-512x512.png
|
||||
|
||||
You can use any icon generator tool or create them manually. The icons should be square and represent your Claude Code UI application.
|
||||
|
||||
For a quick solution, you can:
|
||||
1. Create a simple square PNG icon (512x512)
|
||||
2. Use online tools like realfavicongenerator.net to generate all sizes
|
||||
3. Or use ImageMagick: `convert icon-512x512.png -resize 192x192 icon-192x192.png`
|
||||
BIN
public/logo-128.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/logo-256.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/logo-32.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
public/logo-512.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/logo-64.png
Normal file
|
After Width: | Height: | Size: 870 B |
@@ -1,9 +1,17 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="hsl(262.1 83.3% 57.8%)"/>
|
||||
<path d="M8 9C8 8.44772 8.44772 8 9 8H23C23.5523 8 24 8.44772 24 9V18C24 18.5523 23.5523 19 23 19H12L8 23V9Z"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<rect width="32" height="32" rx="8" fill="hsl(221.2 83.2% 53.3%)"/>
|
||||
<path
|
||||
d="M8 9C8 8.44772 8.44772 8 9 8H23C23.5523 8 24 8.44772 24 9V18C24 18.5523 23.5523 19 23 19H12L8 23V9Z"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 413 B |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Claude Code UI",
|
||||
"short_name": "Claude UI",
|
||||
"description": "Claude Code UI web application",
|
||||
"name": "CloudCLI UI",
|
||||
"short_name": "CloudCLI UI",
|
||||
"description": "CloudCLI UI web application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
|
||||
4
release.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Load environment variables from .env
|
||||
export $(grep -v '^#' .env | grep '^GITHUB_TOKEN=' | xargs)
|
||||
exec npx release-it "$@"
|
||||
67
scripts/fix-node-pty.js
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix node-pty spawn-helper permissions on macOS
|
||||
*
|
||||
* This script fixes a known issue with node-pty where the spawn-helper
|
||||
* binary is shipped without execute permissions, causing "posix_spawnp failed" errors.
|
||||
*
|
||||
* @see https://github.com/microsoft/node-pty/issues/850
|
||||
* @module scripts/fix-node-pty
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Fixes the spawn-helper binary permissions for node-pty on macOS.
|
||||
*
|
||||
* The node-pty package ships the spawn-helper binary without execute permissions
|
||||
* (644 instead of 755), which causes "posix_spawnp failed" errors when trying
|
||||
* to spawn terminal processes.
|
||||
*
|
||||
* This function:
|
||||
* 1. Checks if running on macOS (darwin)
|
||||
* 2. Locates spawn-helper binaries for both arm64 and x64 architectures
|
||||
* 3. Sets execute permissions (755) on each binary found
|
||||
*
|
||||
* @async
|
||||
* @function fixSpawnHelper
|
||||
* @returns {Promise<void>} Resolves when permissions are fixed or skipped
|
||||
* @example
|
||||
* // Run as postinstall script
|
||||
* await fixSpawnHelper();
|
||||
*/
|
||||
async function fixSpawnHelper() {
|
||||
const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
|
||||
|
||||
// Only run on macOS
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const darwinDirs = ['darwin-arm64', 'darwin-x64'];
|
||||
|
||||
for (const dir of darwinDirs) {
|
||||
const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper');
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
await fs.access(spawnHelperPath);
|
||||
|
||||
// Make it executable (755)
|
||||
await fs.chmod(spawnHelperPath, 0o755);
|
||||
console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`);
|
||||
} catch (err) {
|
||||
// File doesn't exist or other error - ignore
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixSpawnHelper().catch(console.error);
|
||||
@@ -13,12 +13,117 @@
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
|
||||
// Session tracking: Map of session IDs to active query instances
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||
|
||||
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
|
||||
|
||||
function createRequestId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function waitForToolApproval(requestId, options = {}) {
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
|
||||
const finalize = (decision) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(decision);
|
||||
};
|
||||
|
||||
let timeout;
|
||||
|
||||
const cleanup = () => {
|
||||
pendingToolApprovals.delete(requestId);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
};
|
||||
|
||||
// timeoutMs 0 = wait indefinitely (interactive tools)
|
||||
if (timeoutMs > 0) {
|
||||
timeout = setTimeout(() => {
|
||||
onCancel?.('timeout');
|
||||
finalize(null);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
onCancel?.('cancelled');
|
||||
finalize({ cancelled: true });
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onCancel?.('cancelled');
|
||||
finalize({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
pendingToolApprovals.set(requestId, (decision) => {
|
||||
finalize(decision);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveToolApproval(requestId, decision) {
|
||||
const resolver = pendingToolApprovals.get(requestId);
|
||||
if (resolver) {
|
||||
resolver(decision);
|
||||
}
|
||||
}
|
||||
|
||||
// Match stored permission entries against a tool + input combo.
|
||||
// This only supports exact tool names and the Bash(command:*) shorthand
|
||||
// used by the UI; it intentionally does not implement full glob semantics,
|
||||
// introduced to stay consistent with the UI's "Allow rule" format.
|
||||
function matchesToolPermission(entry, toolName, input) {
|
||||
if (!entry || !toolName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry === toolName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
|
||||
if (toolName === 'Bash' && bashMatch) {
|
||||
const allowedPrefix = bashMatch[1];
|
||||
let command = '';
|
||||
|
||||
if (typeof input === 'string') {
|
||||
command = input.trim();
|
||||
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
|
||||
command = input.command.trim();
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return command.startsWith(allowedPrefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CLI options to SDK-compatible options format
|
||||
@@ -51,33 +156,43 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||
// When skipping permissions, use bypassPermissions mode
|
||||
sdkOptions.permissionMode = 'bypassPermissions';
|
||||
} else {
|
||||
// Map allowed tools
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
}
|
||||
|
||||
// Add plan mode default tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
}
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// Add plan mode default tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
sdkOptions.allowedTools = allowedTools;
|
||||
}
|
||||
|
||||
// Map disallowed tools
|
||||
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
||||
sdkOptions.disallowedTools = settings.disallowedTools;
|
||||
}
|
||||
}
|
||||
|
||||
sdkOptions.allowedTools = allowedTools;
|
||||
|
||||
// Use the tools preset to make all default built-in tools available (including AskUserQuestion).
|
||||
// This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
|
||||
// but being explicit ensures forward compatibility and clarity.
|
||||
sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
|
||||
|
||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Map model (default to sonnet)
|
||||
sdkOptions.model = options.model || 'sonnet';
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||
console.log(`Using model: ${sdkOptions.model}`);
|
||||
|
||||
// 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) {
|
||||
@@ -135,9 +250,13 @@ function getAllSessions() {
|
||||
* @returns {Object} Transformed message ready for WebSocket
|
||||
*/
|
||||
function transformMessage(sdkMessage) {
|
||||
// SDK messages are already in a format compatible with the frontend
|
||||
// The CLI sends them wrapped in {type: 'claude-response', data: message}
|
||||
// We'll do the same here to maintain compatibility
|
||||
// Extract parent_tool_use_id for subagent tool grouping
|
||||
if (sdkMessage.parent_tool_use_id) {
|
||||
return {
|
||||
...sdkMessage,
|
||||
parentToolUseId: sdkMessage.parent_tool_use_id
|
||||
};
|
||||
}
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
@@ -173,7 +292,7 @@ function extractTokenBudget(resultMessage) {
|
||||
// This is the user's budget limit, not the model's context window
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||
|
||||
console.log(`📊 Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
|
||||
console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
@@ -229,7 +348,7 @@ async function handleImages(command, images, cwd) {
|
||||
modifiedCommand = command + imageNote;
|
||||
}
|
||||
|
||||
console.log(`📸 Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
||||
console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
||||
return { modifiedCommand, tempImagePaths, tempDir };
|
||||
} catch (error) {
|
||||
console.error('Error processing images for SDK:', error);
|
||||
@@ -262,7 +381,7 @@ async function cleanupTempFiles(tempImagePaths, tempDir) {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🧹 Cleaned up ${tempImagePaths.length} temp image files`);
|
||||
console.log(`Cleaned up ${tempImagePaths.length} temp image files`);
|
||||
} catch (error) {
|
||||
console.error('Error during temp file cleanup:', error);
|
||||
}
|
||||
@@ -282,7 +401,7 @@ async function loadMcpConfig(cwd) {
|
||||
await fs.access(claudeConfigPath);
|
||||
} catch (error) {
|
||||
// File doesn't exist, return null
|
||||
console.log('📡 No ~/.claude.json found, proceeding without MCP servers');
|
||||
console.log('No ~/.claude.json found, proceeding without MCP servers');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -292,7 +411,7 @@ async function loadMcpConfig(cwd) {
|
||||
const configContent = await fs.readFile(claudeConfigPath, 'utf8');
|
||||
claudeConfig = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to parse ~/.claude.json:', error.message);
|
||||
console.error('Failed to parse ~/.claude.json:', error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -302,7 +421,7 @@ async function loadMcpConfig(cwd) {
|
||||
// Add global MCP servers
|
||||
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
||||
mcpServers = { ...claudeConfig.mcpServers };
|
||||
console.log(`📡 Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
||||
console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
||||
}
|
||||
|
||||
// Add/override with project-specific MCP servers
|
||||
@@ -310,20 +429,20 @@ async function loadMcpConfig(cwd) {
|
||||
const projectConfig = claudeConfig.claudeProjects[cwd];
|
||||
if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
||||
mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
|
||||
console.log(`📡 Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
|
||||
console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if no servers found
|
||||
if (Object.keys(mcpServers).length === 0) {
|
||||
console.log('📡 No MCP servers configured');
|
||||
console.log('No MCP servers configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`✅ Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
||||
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
||||
return mcpServers;
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading MCP config:', error.message);
|
||||
console.error('Error loading MCP config:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -358,56 +477,140 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
// Create SDK query instance
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||
|
||||
if (!requiresInteraction) {
|
||||
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
|
||||
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isDisallowed) {
|
||||
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
||||
}
|
||||
|
||||
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isAllowed) {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = createRequestId();
|
||||
ws.send({
|
||||
type: 'claude-permission-request',
|
||||
requestId,
|
||||
toolName,
|
||||
input,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
signal: context?.signal,
|
||||
onCancel: (reason) => {
|
||||
ws.send({
|
||||
type: 'claude-permission-cancelled',
|
||||
requestId,
|
||||
reason,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!decision) {
|
||||
return { behavior: 'deny', message: 'Permission request timed out' };
|
||||
}
|
||||
|
||||
if (decision.cancelled) {
|
||||
return { behavior: 'deny', message: 'Permission request cancelled' };
|
||||
}
|
||||
|
||||
if (decision.allow) {
|
||||
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
||||
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
||||
sdkOptions.allowedTools.push(decision.rememberEntry);
|
||||
}
|
||||
if (Array.isArray(sdkOptions.disallowedTools)) {
|
||||
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
|
||||
}
|
||||
}
|
||||
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
|
||||
}
|
||||
|
||||
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
||||
};
|
||||
|
||||
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
|
||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
|
||||
// Restore immediately — Query constructor already captured the value
|
||||
if (prevStreamTimeout !== undefined) {
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
}
|
||||
|
||||
// Track the query instance for abort capability
|
||||
if (capturedSessionId) {
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
}
|
||||
|
||||
// Process streaming messages
|
||||
console.log('🔄 Starting async generator loop for session:', capturedSessionId || 'NEW');
|
||||
console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
|
||||
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);
|
||||
|
||||
// Set session ID on writer
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
ws.setSessionId(capturedSessionId);
|
||||
}
|
||||
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
|
||||
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
|
||||
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
|
||||
}
|
||||
|
||||
// Transform and send message to WebSocket
|
||||
const transformedMessage = transformMessage(message);
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: transformedMessage
|
||||
}));
|
||||
data: transformedMessage,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const tokenBudget = extractTokenBudget(message);
|
||||
if (tokenBudget) {
|
||||
console.log('📊 Token budget from modelUsage:', tokenBudget);
|
||||
ws.send(JSON.stringify({
|
||||
console.log('Token budget from modelUsage:', tokenBudget);
|
||||
ws.send({
|
||||
type: 'token-budget',
|
||||
data: tokenBudget
|
||||
}));
|
||||
data: tokenBudget,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,14 +624,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send completion event
|
||||
console.log('✅ Streaming complete, sending claude-complete event');
|
||||
ws.send(JSON.stringify({
|
||||
console.log('Streaming complete, sending claude-complete event');
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: capturedSessionId,
|
||||
exitCode: 0,
|
||||
isNewSession: !sessionId && !!command
|
||||
}));
|
||||
console.log('📤 claude-complete event sent');
|
||||
});
|
||||
console.log('claude-complete event sent');
|
||||
|
||||
} catch (error) {
|
||||
console.error('SDK query error:', error);
|
||||
@@ -442,10 +645,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send error to WebSocket
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
}));
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -465,7 +669,7 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🛑 Aborting SDK session: ${sessionId}`);
|
||||
console.log(`Aborting SDK session: ${sessionId}`);
|
||||
|
||||
// Call interrupt() on the query instance
|
||||
await session.instance.interrupt();
|
||||
@@ -509,5 +713,6 @@ export {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval
|
||||
};
|
||||
|
||||
327
server/cli.js
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/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 os from 'os';
|
||||
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(os.homedir(), '.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('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
|
||||
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
|
||||
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
|
||||
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
|
||||
}
|
||||
|
||||
// Show help
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ Claude Code UI - Command Line Tool ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Usage:
|
||||
claude-code-ui [command] [options]
|
||||
cloudcli [command] [options]
|
||||
|
||||
Commands:
|
||||
start Start the Claude Code UI server (default)
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
version Show version information
|
||||
|
||||
Options:
|
||||
-p, --port <port> Set server port (default: 3001)
|
||||
--database-path <path> Set custom database location
|
||||
-h, --help Show this help information
|
||||
-v, --version Show version information
|
||||
|
||||
Examples:
|
||||
$ cloudcli # Start with defaults
|
||||
$ cloudcli --port 8080 # Start on port 8080
|
||||
$ cloudcli -p 3000 # Short form for port
|
||||
$ cloudcli start --port 4000 # Explicit start command
|
||||
$ cloudcli status # Show configuration
|
||||
|
||||
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)
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Compare semver versions, returns true if v1 > v2
|
||||
function isNewerVersion(v1, v2) {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (parts1[i] > parts2[i]) return true;
|
||||
if (parts1[i] < parts2[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
async function checkForUpdates(silent = false) {
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
if (isNewerVersion(latestVersion, currentVersion)) {
|
||||
console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);
|
||||
console.log(` Run ${c.bright('cloudcli update')} to update\n`);
|
||||
return { hasUpdate: true, latestVersion, currentVersion };
|
||||
} else if (!silent) {
|
||||
console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);
|
||||
}
|
||||
return { hasUpdate: false, latestVersion, currentVersion };
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
console.log(`${c.warn('[WARN]')} Could not check for updates`);
|
||||
}
|
||||
return { hasUpdate: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Update the package
|
||||
async function updatePackage() {
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
console.log(`${c.info('[INFO]')} Checking for updates...`);
|
||||
|
||||
const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);
|
||||
|
||||
if (!hasUpdate) {
|
||||
console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
|
||||
execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });
|
||||
console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);
|
||||
} catch (e) {
|
||||
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
|
||||
console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
async function startServer() {
|
||||
// Check for updates silently on startup
|
||||
checkForUpdates(true);
|
||||
|
||||
// Import and run the server
|
||||
await import('./index.js');
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
function parseArgs(args) {
|
||||
const parsed = { command: 'start', options: {} };
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--port' || arg === '-p') {
|
||||
parsed.options.port = args[++i];
|
||||
} else if (arg.startsWith('--port=')) {
|
||||
parsed.options.port = arg.split('=')[1];
|
||||
} else if (arg === '--database-path') {
|
||||
parsed.options.databasePath = args[++i];
|
||||
} else if (arg.startsWith('--database-path=')) {
|
||||
parsed.options.databasePath = arg.split('=')[1];
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.command = 'help';
|
||||
} else if (arg === '--version' || arg === '-v') {
|
||||
parsed.command = 'version';
|
||||
} else if (!arg.startsWith('-')) {
|
||||
parsed.command = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Main CLI handler
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const { command, options } = parseArgs(args);
|
||||
|
||||
// Apply CLI options to environment variables
|
||||
if (options.port) {
|
||||
process.env.PORT = options.port;
|
||||
}
|
||||
if (options.databasePath) {
|
||||
process.env.DATABASE_PATH = options.databasePath;
|
||||
}
|
||||
|
||||
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;
|
||||
case 'update':
|
||||
await updatePackage();
|
||||
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);
|
||||
});
|
||||
5
server/constants/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Environment Flag: Is Platform
|
||||
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||
*/
|
||||
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
@@ -94,32 +94,39 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
||||
}
|
||||
|
||||
// Set session ID on writer (for API endpoint compatibility)
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
ws.setSessionId(capturedSessionId);
|
||||
}
|
||||
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId,
|
||||
model: response.model,
|
||||
cwd: response.cwd
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send system info to frontend
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-system',
|
||||
data: response
|
||||
}));
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// Forward user message
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-user',
|
||||
data: response
|
||||
}));
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
break;
|
||||
|
||||
case 'assistant':
|
||||
@@ -129,7 +136,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
messageBuffer += textContent;
|
||||
|
||||
// Send as Claude-compatible format for frontend
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
@@ -137,8 +144,9 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
type: 'text_delta',
|
||||
text: textContent
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -148,37 +156,40 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
|
||||
// Send final message if we have buffered content
|
||||
if (messageBuffer) {
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_stop'
|
||||
}
|
||||
}));
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-result',
|
||||
sessionId: capturedSessionId || sessionId,
|
||||
data: response,
|
||||
success: response.subtype === 'success'
|
||||
}));
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Forward any other message types
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-response',
|
||||
data: response
|
||||
}));
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log('📄 Non-JSON response:', line);
|
||||
// If not JSON, send as raw text
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-output',
|
||||
data: line
|
||||
}));
|
||||
data: line,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -186,10 +197,11 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Handle stderr
|
||||
cursorProcess.stderr.on('data', (data) => {
|
||||
console.error('Cursor CLI stderr:', data.toString());
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: data.toString()
|
||||
}));
|
||||
error: data.toString(),
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
@@ -200,12 +212,12 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: finalSessionId,
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
}));
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
@@ -221,12 +233,13 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: error.message
|
||||
}));
|
||||
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
@@ -259,4 +272,4 @@ export {
|
||||
abortCursorSession,
|
||||
isCursorSessionActive,
|
||||
getActiveCursorSessions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,18 +1,102 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'auth.db');
|
||||
// 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');
|
||||
|
||||
// Ensure database directory exists if custom path is provided
|
||||
if (process.env.DATABASE_PATH) {
|
||||
const dbDir = path.dirname(DB_PATH);
|
||||
try {
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
console.log(`Created database directory: ${dbDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create database directory ${dbDir}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
|
||||
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
|
||||
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
|
||||
try {
|
||||
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
|
||||
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
|
||||
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
const db = new Database(DB_PATH);
|
||||
console.log('Connected to SQLite database');
|
||||
|
||||
// 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('');
|
||||
|
||||
const runMigrations = () => {
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
|
||||
const columnNames = tableInfo.map(col => col.name);
|
||||
|
||||
if (!columnNames.includes('git_name')) {
|
||||
console.log('Running migration: Adding git_name column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
|
||||
}
|
||||
|
||||
if (!columnNames.includes('git_email')) {
|
||||
console.log('Running migration: Adding git_email column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
|
||||
}
|
||||
|
||||
if (!columnNames.includes('has_completed_onboarding')) {
|
||||
console.log('Running migration: Adding has_completed_onboarding column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database with schema
|
||||
const initializeDatabase = async () => {
|
||||
@@ -20,6 +104,7 @@ const initializeDatabase = async () => {
|
||||
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
||||
db.exec(initSQL);
|
||||
console.log('Database initialized successfully');
|
||||
runMigrations();
|
||||
} catch (error) {
|
||||
console.error('Error initializing database:', error.message);
|
||||
throw error;
|
||||
@@ -59,12 +144,12 @@ const userDb = {
|
||||
}
|
||||
},
|
||||
|
||||
// Update last login time
|
||||
// Update last login time (non-fatal — logged but not thrown)
|
||||
updateLastLogin: (userId) => {
|
||||
try {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
console.warn('Failed to update last login:', err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -76,11 +161,217 @@ 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;
|
||||
}
|
||||
},
|
||||
|
||||
updateGitConfig: (userId, gitName, gitEmail) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
|
||||
stmt.run(gitName, gitEmail, userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getGitConfig: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
completeOnboarding: (userId) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
|
||||
stmt.run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
hasCompletedOnboarding: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
|
||||
return row?.has_completed_onboarding === 1;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// API Keys database operations
|
||||
const apiKeysDb = {
|
||||
// Generate a new API key
|
||||
generateApiKey: () => {
|
||||
return 'ck_' + crypto.randomBytes(32).toString('hex');
|
||||
},
|
||||
|
||||
// Create a new API key
|
||||
createApiKey: (userId, keyName) => {
|
||||
try {
|
||||
const apiKey = apiKeysDb.generateApiKey();
|
||||
const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
|
||||
const result = stmt.run(userId, keyName, apiKey);
|
||||
return { id: result.lastInsertRowid, keyName, apiKey };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get all API keys for a user
|
||||
getApiKeys: (userId) => {
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Validate API key and get user
|
||||
validateApiKey: (apiKey) => {
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT u.id, u.username, ak.id as api_key_id
|
||||
FROM api_keys ak
|
||||
JOIN users u ON ak.user_id = u.id
|
||||
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
|
||||
`).get(apiKey);
|
||||
|
||||
if (row) {
|
||||
// Update last_used timestamp
|
||||
db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete an API key
|
||||
deleteApiKey: (userId, apiKeyId) => {
|
||||
try {
|
||||
const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle API key active status
|
||||
toggleApiKey: (userId, apiKeyId, isActive) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
|
||||
const credentialsDb = {
|
||||
// Create a new credential
|
||||
createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
|
||||
try {
|
||||
const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
|
||||
const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
|
||||
return { id: result.lastInsertRowid, credentialName, credentialType };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get all credentials for a user, optionally filtered by type
|
||||
getCredentials: (userId, credentialType = null) => {
|
||||
try {
|
||||
let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
if (credentialType) {
|
||||
query += ' AND credential_type = ?';
|
||||
params.push(credentialType);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const rows = db.prepare(query).all(...params);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get active credential value for a user by type (returns most recent active)
|
||||
getActiveCredential: (userId, credentialType) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
|
||||
return row?.credential_value || null;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a credential
|
||||
deleteCredential: (userId, credentialId) => {
|
||||
try {
|
||||
const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(credentialId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle credential active status
|
||||
toggleCredential: (userId, credentialId, isActive) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
|
||||
},
|
||||
getGithubTokens: (userId) => {
|
||||
return credentialsDb.getCredentials(userId, 'github_token');
|
||||
},
|
||||
getActiveGithubToken: (userId) => {
|
||||
return credentialsDb.getActiveCredential(userId, 'github_token');
|
||||
},
|
||||
deleteGithubToken: (userId, tokenId) => {
|
||||
return credentialsDb.deleteCredential(userId, tokenId);
|
||||
},
|
||||
toggleGithubToken: (userId, tokenId, isActive) => {
|
||||
return credentialsDb.toggleCredential(userId, tokenId, isActive);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
db,
|
||||
initializeDatabase,
|
||||
userDb
|
||||
userDb,
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
@@ -8,9 +8,45 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
git_name TEXT,
|
||||
git_email TEXT,
|
||||
has_completed_onboarding BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
|
||||
-- API Keys table for external API access
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
key_name TEXT NOT NULL,
|
||||
api_key TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||
|
||||
-- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.)
|
||||
CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_name TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
|
||||
credential_value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
1017
server/index.js
29
server/load-env.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Load environment variables from .env before other imports execute.
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
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) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_PATH) {
|
||||
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
@@ -20,8 +21,29 @@ const validateApiKey = (req, res, next) => {
|
||||
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
// Platform mode: use single database user
|
||||
if (IS_PLATFORM) {
|
||||
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
|
||||
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
// Also check query param for SSE endpoints (EventSource can't set headers)
|
||||
if (!token && req.query.token) {
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||
@@ -29,13 +51,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 +80,25 @@ const generateToken = (user) => {
|
||||
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
// Platform mode: bypass token validation, return first user
|
||||
if (IS_PLATFORM) {
|
||||
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;
|
||||
|
||||
403
server/openai-codex.js
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* OpenAI Codex SDK Integration
|
||||
* =============================
|
||||
*
|
||||
* This module provides integration with the OpenAI Codex SDK for non-interactive
|
||||
* chat sessions. It mirrors the pattern used in claude-sdk.js for consistency.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket
|
||||
* - abortCodexSession(sessionId) - Cancel an active session
|
||||
* - isCodexSessionActive(sessionId) - Check if a session is running
|
||||
* - getActiveCodexSessions() - List all active sessions
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
|
||||
/**
|
||||
* Transform Codex SDK event to WebSocket message format
|
||||
* @param {object} event - SDK event
|
||||
* @returns {object} - Transformed event for WebSocket
|
||||
*/
|
||||
function transformCodexEvent(event) {
|
||||
// Map SDK event types to a consistent format
|
||||
switch (event.type) {
|
||||
case 'item.started':
|
||||
case 'item.updated':
|
||||
case 'item.completed':
|
||||
const item = event.item;
|
||||
if (!item) {
|
||||
return { type: event.type, item: null };
|
||||
}
|
||||
|
||||
// Transform based on item type
|
||||
switch (item.type) {
|
||||
case 'agent_message':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'agent_message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: item.text
|
||||
}
|
||||
};
|
||||
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'reasoning',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: item.text,
|
||||
isReasoning: true
|
||||
}
|
||||
};
|
||||
|
||||
case 'command_execution':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'command_execution',
|
||||
command: item.command,
|
||||
output: item.aggregated_output,
|
||||
exitCode: item.exit_code,
|
||||
status: item.status
|
||||
};
|
||||
|
||||
case 'file_change':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'file_change',
|
||||
changes: item.changes,
|
||||
status: item.status
|
||||
};
|
||||
|
||||
case 'mcp_tool_call':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'mcp_tool_call',
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
arguments: item.arguments,
|
||||
result: item.result,
|
||||
error: item.error,
|
||||
status: item.status
|
||||
};
|
||||
|
||||
case 'web_search':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'web_search',
|
||||
query: item.query
|
||||
};
|
||||
|
||||
case 'todo_list':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'todo_list',
|
||||
items: item.items
|
||||
};
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'error',
|
||||
message: {
|
||||
role: 'error',
|
||||
content: item.message
|
||||
}
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: item.type,
|
||||
item: item
|
||||
};
|
||||
}
|
||||
|
||||
case 'turn.started':
|
||||
return {
|
||||
type: 'turn_started'
|
||||
};
|
||||
|
||||
case 'turn.completed':
|
||||
return {
|
||||
type: 'turn_complete',
|
||||
usage: event.usage
|
||||
};
|
||||
|
||||
case 'turn.failed':
|
||||
return {
|
||||
type: 'turn_failed',
|
||||
error: event.error
|
||||
};
|
||||
|
||||
case 'thread.started':
|
||||
return {
|
||||
type: 'thread_started',
|
||||
threadId: event.id
|
||||
};
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
message: event.message
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: event.type,
|
||||
data: event
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map permission mode to Codex SDK options
|
||||
* @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions'
|
||||
* @returns {object} - { sandboxMode, approvalPolicy }
|
||||
*/
|
||||
function mapPermissionModeToCodexOptions(permissionMode) {
|
||||
switch (permissionMode) {
|
||||
case 'acceptEdits':
|
||||
return {
|
||||
sandboxMode: 'workspace-write',
|
||||
approvalPolicy: 'never'
|
||||
};
|
||||
case 'bypassPermissions':
|
||||
return {
|
||||
sandboxMode: 'danger-full-access',
|
||||
approvalPolicy: 'never'
|
||||
};
|
||||
case 'default':
|
||||
default:
|
||||
return {
|
||||
sandboxMode: 'workspace-write',
|
||||
approvalPolicy: 'untrusted'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Codex query with streaming
|
||||
* @param {string} command - The prompt to send
|
||||
* @param {object} options - Options including cwd, sessionId, model, permissionMode
|
||||
* @param {WebSocket|object} ws - WebSocket connection or response writer
|
||||
*/
|
||||
export async function queryCodex(command, options = {}, ws) {
|
||||
const {
|
||||
sessionId,
|
||||
cwd,
|
||||
projectPath,
|
||||
model,
|
||||
permissionMode = 'default'
|
||||
} = options;
|
||||
|
||||
const workingDirectory = cwd || projectPath || process.cwd();
|
||||
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
|
||||
|
||||
let codex;
|
||||
let thread;
|
||||
let currentSessionId = sessionId;
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Initialize Codex SDK
|
||||
codex = new Codex();
|
||||
|
||||
// Thread options with sandbox and approval settings
|
||||
const threadOptions = {
|
||||
workingDirectory,
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode,
|
||||
approvalPolicy,
|
||||
model
|
||||
};
|
||||
|
||||
// Start or resume thread
|
||||
if (sessionId) {
|
||||
thread = codex.resumeThread(sessionId, threadOptions);
|
||||
} else {
|
||||
thread = codex.startThread(threadOptions);
|
||||
}
|
||||
|
||||
// Get the thread ID
|
||||
currentSessionId = thread.id || sessionId || `codex-${Date.now()}`;
|
||||
|
||||
// Track the session
|
||||
activeCodexSessions.set(currentSessionId, {
|
||||
thread,
|
||||
codex,
|
||||
status: 'running',
|
||||
abortController,
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send session created event
|
||||
sendMessage(ws, {
|
||||
type: 'session-created',
|
||||
sessionId: currentSessionId,
|
||||
provider: 'codex'
|
||||
});
|
||||
|
||||
// Execute with streaming
|
||||
const streamedTurn = await thread.runStreamed(command, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
for await (const event of streamedTurn.events) {
|
||||
// Check if session was aborted
|
||||
const session = activeCodexSessions.get(currentSessionId);
|
||||
if (!session || session.status === 'aborted') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'item.started' || event.type === 'item.updated') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformed = transformCodexEvent(event);
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'codex-response',
|
||||
data: transformed,
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
|
||||
// Extract and send token usage if available (normalized to match Claude format)
|
||||
if (event.type === 'turn.completed' && event.usage) {
|
||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
||||
sendMessage(ws, {
|
||||
type: 'token-budget',
|
||||
data: {
|
||||
used: totalTokens,
|
||||
total: 200000 // Default context window for Codex models
|
||||
},
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
sendMessage(ws, {
|
||||
type: 'codex-complete',
|
||||
sessionId: currentSessionId,
|
||||
actualSessionId: thread.id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
|
||||
const wasAborted =
|
||||
session?.status === 'aborted' ||
|
||||
error?.name === 'AbortError' ||
|
||||
String(error?.message || '').toLowerCase().includes('aborted');
|
||||
|
||||
if (!wasAborted) {
|
||||
console.error('[Codex] Error:', error);
|
||||
sendMessage(ws, {
|
||||
type: 'codex-error',
|
||||
error: error.message,
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Update session status
|
||||
if (currentSessionId) {
|
||||
const session = activeCodexSessions.get(currentSessionId);
|
||||
if (session) {
|
||||
session.status = session.status === 'aborted' ? 'aborted' : 'completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an active Codex session
|
||||
* @param {string} sessionId - Session ID to abort
|
||||
* @returns {boolean} - Whether abort was successful
|
||||
*/
|
||||
export function abortCodexSession(sessionId) {
|
||||
const session = activeCodexSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.status = 'aborted';
|
||||
try {
|
||||
session.abortController?.abort();
|
||||
} catch (error) {
|
||||
console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is active
|
||||
* @param {string} sessionId - Session ID to check
|
||||
* @returns {boolean} - Whether session is active
|
||||
*/
|
||||
export function isCodexSessionActive(sessionId) {
|
||||
const session = activeCodexSessions.get(sessionId);
|
||||
return session?.status === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
* @returns {Array} - Array of active session info
|
||||
*/
|
||||
export function getActiveCodexSessions() {
|
||||
const sessions = [];
|
||||
|
||||
for (const [id, session] of activeCodexSessions.entries()) {
|
||||
if (session.status === 'running') {
|
||||
sessions.push({
|
||||
id,
|
||||
status: session.status,
|
||||
startedAt: session.startedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to send message via WebSocket or writer
|
||||
* @param {WebSocket|object} ws - WebSocket or response writer
|
||||
* @param {object} data - Data to send
|
||||
*/
|
||||
function sendMessage(ws, data) {
|
||||
try {
|
||||
if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
|
||||
// Writer handles stringification (SSEStreamWriter or WebSocketWriter)
|
||||
ws.send(data);
|
||||
} else if (typeof ws.send === 'function') {
|
||||
// Raw WebSocket - stringify here
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Codex] Error sending message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old completed sessions periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const maxAge = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
for (const [id, session] of activeCodexSessions.entries()) {
|
||||
if (session.status !== 'running') {
|
||||
const startedAt = new Date(session.startedAt).getTime();
|
||||
if (now - startedAt > maxAge) {
|
||||
activeCodexSessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
@@ -204,7 +204,7 @@ function clearProjectDirectoryCache() {
|
||||
|
||||
// Load project configuration file
|
||||
async function loadProjectConfig() {
|
||||
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
|
||||
const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
|
||||
try {
|
||||
const configData = await fs.readFile(configPath, 'utf8');
|
||||
return JSON.parse(configData);
|
||||
@@ -216,7 +216,7 @@ async function loadProjectConfig() {
|
||||
|
||||
// Save project configuration file
|
||||
async function saveProjectConfig(config) {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude');
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
const configPath = path.join(claudeDir, 'project-config.json');
|
||||
|
||||
// Ensure the .claude directory exists
|
||||
@@ -266,9 +266,17 @@ async function extractProjectDirectory(projectName) {
|
||||
if (projectDirectoryCache.has(projectName)) {
|
||||
return projectDirectoryCache.get(projectName);
|
||||
}
|
||||
|
||||
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
// Check project config for originalPath (manually added projects via UI or platform)
|
||||
// This handles projects with dashes in their directory names correctly
|
||||
const config = await loadProjectConfig();
|
||||
if (config[projectName]?.originalPath) {
|
||||
const originalPath = config[projectName].originalPath;
|
||||
projectDirectoryCache.set(projectName, originalPath);
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
const cwdCounts = new Map();
|
||||
let latestTimestamp = 0;
|
||||
let latestCwd = null;
|
||||
@@ -371,24 +379,47 @@ async function extractProjectDirectory(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjects() {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
|
||||
async function getProjects(progressCallback = null) {
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
const projects = [];
|
||||
const existingProjects = new Set();
|
||||
|
||||
const codexSessionsIndexRef = { sessionsByProject: null };
|
||||
let totalProjects = 0;
|
||||
let processedProjects = 0;
|
||||
let directories = [];
|
||||
|
||||
try {
|
||||
// Check if the .claude/projects directory exists
|
||||
await fs.access(claudeDir);
|
||||
|
||||
|
||||
// First, get existing Claude projects from the file system
|
||||
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
existingProjects.add(entry.name);
|
||||
const projectPath = path.join(claudeDir, entry.name);
|
||||
|
||||
directories = entries.filter(e => e.isDirectory());
|
||||
|
||||
// Build set of existing project names for later
|
||||
directories.forEach(e => existingProjects.add(e.name));
|
||||
|
||||
// Count manual projects not already in directories
|
||||
const manualProjectsCount = Object.entries(config)
|
||||
.filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name))
|
||||
.length;
|
||||
|
||||
totalProjects = directories.length + manualProjectsCount;
|
||||
|
||||
for (const entry of directories) {
|
||||
processedProjects++;
|
||||
|
||||
// Emit progress
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: entry.name
|
||||
});
|
||||
}
|
||||
|
||||
// Extract actual project directory from JSONL sessions
|
||||
const actualProjectDir = await extractProjectDirectory(entry.name);
|
||||
|
||||
@@ -403,7 +434,11 @@ async function getProjects() {
|
||||
displayName: customName || autoDisplayName,
|
||||
fullPath: fullPath,
|
||||
isCustomName: !!customName,
|
||||
sessions: []
|
||||
sessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Try to get sessions for this project (just first 5 for performance)
|
||||
@@ -416,6 +451,10 @@ async function getProjects() {
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
|
||||
project.sessionMeta = {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Also fetch Cursor sessions for this project
|
||||
@@ -425,7 +464,17 @@ async function getProjects() {
|
||||
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
|
||||
|
||||
// Also fetch Codex sessions for this project
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||
indexRef: codexSessionsIndexRef,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||
project.codexSessions = [];
|
||||
}
|
||||
|
||||
// Add TaskMaster detection
|
||||
try {
|
||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||
@@ -444,20 +493,35 @@ async function getProjects() {
|
||||
status: 'error'
|
||||
};
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error reading projects directory:', error);
|
||||
}
|
||||
// Calculate total for manual projects only (no directories exist)
|
||||
totalProjects = Object.entries(config)
|
||||
.filter(([name, cfg]) => cfg.manuallyAdded)
|
||||
.length;
|
||||
}
|
||||
|
||||
// Add manually configured projects that don't exist as folders yet
|
||||
for (const [projectName, projectConfig] of Object.entries(config)) {
|
||||
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
|
||||
processedProjects++;
|
||||
|
||||
// Emit progress for manual projects
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: projectName
|
||||
});
|
||||
}
|
||||
|
||||
// Use the original path if available, otherwise extract from potential sessions
|
||||
let actualProjectDir = projectConfig.originalPath;
|
||||
|
||||
@@ -470,7 +534,7 @@ async function getProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
const project = {
|
||||
const project = {
|
||||
name: projectName,
|
||||
path: actualProjectDir,
|
||||
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
|
||||
@@ -478,16 +542,30 @@ async function getProjects() {
|
||||
isCustomName: !!projectConfig.displayName,
|
||||
isManuallyAdded: true,
|
||||
sessions: [],
|
||||
cursorSessions: []
|
||||
};
|
||||
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
},
|
||||
cursorSessions: [],
|
||||
codexSessions: []
|
||||
};
|
||||
|
||||
// Try to fetch Cursor sessions for manual projects too
|
||||
try {
|
||||
project.cursorSessions = await getCursorSessions(actualProjectDir);
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
|
||||
|
||||
// Try to fetch Codex sessions for manual projects too
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||
indexRef: codexSessionsIndexRef,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
|
||||
// Add TaskMaster detection for manual projects
|
||||
try {
|
||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||
@@ -517,16 +595,27 @@ async function getProjects() {
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Emit completion after all projects (including manual) are processed
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'complete',
|
||||
current: totalProjects,
|
||||
total: totalProjects
|
||||
});
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
async function getSessions(projectName, limit = 5, offset = 0) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
||||
// agent-*.jsonl files contain session start data at this point. This needs to be revisited
|
||||
// periodically to make sure only accurate data is there and no new functionality is added there
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
||||
|
||||
if (jsonlFiles.length === 0) {
|
||||
return { sessions: [], hasMore: false, total: 0 };
|
||||
@@ -800,20 +889,81 @@ async function parseJsonlSessions(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an agent JSONL file and extract tool uses
|
||||
async function parseAgentTools(filePath) {
|
||||
const tools = [];
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Look for assistant messages with tool_use
|
||||
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content) {
|
||||
if (part.type === 'tool_use') {
|
||||
tools.push({
|
||||
toolId: part.id,
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
timestamp: entry.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for tool results
|
||||
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
// Find the matching tool and add result
|
||||
const tool = tools.find(t => t.toolId === part.tool_use_id);
|
||||
if (tool) {
|
||||
tool.toolResult = {
|
||||
content: typeof part.content === 'string' ? part.content :
|
||||
Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') :
|
||||
JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error parsing agent file ${filePath}:`, error.message);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// Get messages for a specific session with pagination support
|
||||
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
||||
|
||||
// agent-*.jsonl files contain subagent tool history - we'll process them separately
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
||||
const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
||||
|
||||
if (jsonlFiles.length === 0) {
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
|
||||
const messages = [];
|
||||
|
||||
// Map of agentId -> tools for subagent tool grouping
|
||||
const agentToolsCache = new Map();
|
||||
|
||||
// Process all JSONL files to find messages for this session
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
@@ -822,7 +972,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
@@ -836,26 +986,55 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Collect agentIds from Task tool results
|
||||
const agentIds = new Set();
|
||||
for (const message of messages) {
|
||||
if (message.toolUseResult?.agentId) {
|
||||
agentIds.add(message.toolUseResult.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Load agent tools for each agentId found
|
||||
for (const agentId of agentIds) {
|
||||
const agentFileName = `agent-${agentId}.jsonl`;
|
||||
if (agentFiles.includes(agentFileName)) {
|
||||
const agentFilePath = path.join(projectDir, agentFileName);
|
||||
const tools = await parseAgentTools(agentFilePath);
|
||||
agentToolsCache.set(agentId, tools);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach agent tools to their parent Task messages
|
||||
for (const message of messages) {
|
||||
if (message.toolUseResult?.agentId) {
|
||||
const agentId = message.toolUseResult.agentId;
|
||||
const agentTools = agentToolsCache.get(agentId);
|
||||
if (agentTools && agentTools.length > 0) {
|
||||
message.subagentTools = agentTools;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort messages by timestamp
|
||||
const sortedMessages = messages.sort((a, b) =>
|
||||
const sortedMessages = messages.sort((a, b) =>
|
||||
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
||||
);
|
||||
|
||||
|
||||
const total = sortedMessages.length;
|
||||
|
||||
|
||||
// If no limit is specified, return all messages (backward compatibility)
|
||||
if (limit === null) {
|
||||
return sortedMessages;
|
||||
}
|
||||
|
||||
|
||||
// Apply pagination - for recent messages, we need to slice from the end
|
||||
// offset 0 should give us the most recent messages
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
@@ -889,7 +1068,7 @@ async function renameProject(projectName, newDisplayName) {
|
||||
|
||||
// Delete a session from a project
|
||||
async function deleteSession(projectName, sessionId) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
@@ -950,25 +1129,56 @@ async function isProjectEmpty(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an empty project
|
||||
async function deleteProject(projectName) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
// Delete a project (force=true to delete even with sessions)
|
||||
async function deleteProject(projectName, force = false) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
// First check if the project is empty
|
||||
const isEmpty = await isProjectEmpty(projectName);
|
||||
if (!isEmpty) {
|
||||
if (!isEmpty && !force) {
|
||||
throw new Error('Cannot delete project with existing sessions');
|
||||
}
|
||||
|
||||
// Remove the project directory
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Remove from project config
|
||||
|
||||
const config = await loadProjectConfig();
|
||||
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
|
||||
|
||||
// Fallback to extractProjectDirectory if projectPath is not in config
|
||||
if (!projectPath) {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
}
|
||||
|
||||
// Remove the project directory (includes all Claude sessions)
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Delete all Codex sessions associated with this project
|
||||
if (projectPath) {
|
||||
try {
|
||||
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
|
||||
for (const session of codexSessions) {
|
||||
try {
|
||||
await deleteCodexSession(session.id);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete Codex sessions:', err.message);
|
||||
}
|
||||
|
||||
// Delete Cursor sessions directory if it exists
|
||||
try {
|
||||
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
||||
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Cursor dir may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from project config
|
||||
delete config[projectName];
|
||||
await saveProjectConfig(config);
|
||||
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting project ${projectName}:`, error);
|
||||
@@ -979,20 +1189,20 @@ async function deleteProject(projectName) {
|
||||
// Add a project manually to the config (without creating folders)
|
||||
async function addProjectManually(projectPath, displayName = null) {
|
||||
const absolutePath = path.resolve(projectPath);
|
||||
|
||||
|
||||
try {
|
||||
// Check if the path exists
|
||||
await fs.access(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/\//g, '-');
|
||||
|
||||
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
|
||||
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
if (config[projectName]) {
|
||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||
@@ -1000,13 +1210,13 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
|
||||
// Allow adding projects even if the directory exists - this enables tracking
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
manuallyAdded: true,
|
||||
originalPath: absolutePath
|
||||
};
|
||||
|
||||
|
||||
if (displayName) {
|
||||
config[projectName].displayName = displayName;
|
||||
}
|
||||
@@ -1137,6 +1347,465 @@ async function getCursorSessions(projectPath) {
|
||||
}
|
||||
|
||||
|
||||
function normalizeComparablePath(inputPath) {
|
||||
if (!inputPath || typeof inputPath !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\')
|
||||
? inputPath.slice(4)
|
||||
: inputPath;
|
||||
const normalized = path.normalize(withoutLongPathPrefix.trim());
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const resolved = path.resolve(normalized);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
async function findCodexJsonlFiles(dir) {
|
||||
const files = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await findCodexJsonlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function buildCodexSessionsIndex() {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
const sessionsByProject = new Map();
|
||||
|
||||
try {
|
||||
await fs.access(codexSessionsDir);
|
||||
} catch (error) {
|
||||
return sessionsByProject;
|
||||
}
|
||||
|
||||
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
||||
|
||||
for (const filePath of jsonlFiles) {
|
||||
try {
|
||||
const sessionData = await parseCodexSessionFile(filePath);
|
||||
if (!sessionData || !sessionData.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedProjectPath = normalizeComparablePath(sessionData.cwd);
|
||||
if (!normalizedProjectPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: sessionData.id,
|
||||
summary: sessionData.summary || 'Codex Session',
|
||||
messageCount: sessionData.messageCount || 0,
|
||||
lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
|
||||
cwd: sessionData.cwd,
|
||||
model: sessionData.model,
|
||||
filePath,
|
||||
provider: 'codex',
|
||||
};
|
||||
|
||||
if (!sessionsByProject.has(normalizedProjectPath)) {
|
||||
sessionsByProject.set(normalizedProjectPath, []);
|
||||
}
|
||||
|
||||
sessionsByProject.get(normalizedProjectPath).push(session);
|
||||
} catch (error) {
|
||||
console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessions of sessionsByProject.values()) {
|
||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
}
|
||||
|
||||
return sessionsByProject;
|
||||
}
|
||||
|
||||
// Fetch Codex sessions for a given project path
|
||||
async function getCodexSessions(projectPath, options = {}) {
|
||||
const { limit = 5, indexRef = null } = options;
|
||||
try {
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (indexRef && !indexRef.sessionsByProject) {
|
||||
indexRef.sessionsByProject = await buildCodexSessionsIndex();
|
||||
}
|
||||
|
||||
const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();
|
||||
const sessions = sessionsByProject.get(normalizedProjectPath) || [];
|
||||
|
||||
// Return limited sessions for performance (0 = unlimited for deletion)
|
||||
return limit > 0 ? sessions.slice(0, limit) : [...sessions];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a Codex session JSONL file to extract metadata
|
||||
async function parseCodexSessionFile(filePath) {
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
let sessionMeta = null;
|
||||
let lastTimestamp = null;
|
||||
let lastUserMessage = null;
|
||||
let messageCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Track timestamp
|
||||
if (entry.timestamp) {
|
||||
lastTimestamp = entry.timestamp;
|
||||
}
|
||||
|
||||
// Extract session metadata
|
||||
if (entry.type === 'session_meta' && entry.payload) {
|
||||
sessionMeta = {
|
||||
id: entry.payload.id,
|
||||
cwd: entry.payload.cwd,
|
||||
model: entry.payload.model || entry.payload.model_provider,
|
||||
timestamp: entry.timestamp,
|
||||
git: entry.payload.git
|
||||
};
|
||||
}
|
||||
|
||||
// Count messages and extract user messages for summary
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||
messageCount++;
|
||||
if (entry.payload.message) {
|
||||
lastUserMessage = entry.payload.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
|
||||
messageCount++;
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMeta) {
|
||||
return {
|
||||
...sessionMeta,
|
||||
timestamp: lastTimestamp || sessionMeta.timestamp,
|
||||
summary: lastUserMessage ?
|
||||
(lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :
|
||||
'Codex Session',
|
||||
messageCount
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing Codex session file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get messages for a specific Codex session
|
||||
async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
console.warn(`Codex session file not found for session ${sessionId}`);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
let tokenUsage = null;
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
// Helper to extract text from Codex content array
|
||||
const extractText = (content) => {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content
|
||||
.map(item => {
|
||||
if (item.type === 'input_text' || item.type === 'output_text') {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === 'text') {
|
||||
return item.text;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Extract token usage from token_count events (keep latest)
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const info = entry.payload.info;
|
||||
if (info.total_token_usage) {
|
||||
tokenUsage = {
|
||||
used: info.total_token_usage.total_tokens || 0,
|
||||
total: info.model_context_window || 200000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract messages from response_item
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
||||
const content = entry.payload.content;
|
||||
const role = entry.payload.role || 'assistant';
|
||||
const textContent = extractText(content);
|
||||
|
||||
// Skip system context messages (environment_context)
|
||||
if (textContent?.includes('<environment_context>')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only add if there's actual content
|
||||
if (textContent?.trim()) {
|
||||
messages.push({
|
||||
type: role === 'user' ? 'user' : 'assistant',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: role,
|
||||
content: textContent
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
|
||||
const summaryText = entry.payload.summary
|
||||
?.map(s => s.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (summaryText?.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: summaryText
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
let toolName = entry.payload.name;
|
||||
let toolInput = entry.payload.arguments;
|
||||
|
||||
// Map Codex tool names to Claude equivalents
|
||||
if (toolName === 'shell_command') {
|
||||
toolName = 'Bash';
|
||||
try {
|
||||
const args = JSON.parse(entry.payload.arguments);
|
||||
toolInput = JSON.stringify({ command: args.command });
|
||||
} catch (e) {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: toolName,
|
||||
toolInput: toolInput,
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
||||
const toolName = entry.payload.name || 'custom_tool';
|
||||
const input = entry.payload.input || '';
|
||||
|
||||
if (toolName === 'apply_patch') {
|
||||
// Parse Codex patch format and convert to Claude Edit format
|
||||
const fileMatch = input.match(/\*\*\* Update File: (.+)/);
|
||||
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
||||
|
||||
// Extract old and new content from patch
|
||||
const lines = input.split('\n');
|
||||
const oldLines = [];
|
||||
const newLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
oldLines.push(line.substring(1));
|
||||
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
newLines.push(line.substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: 'Edit',
|
||||
toolInput: JSON.stringify({
|
||||
file_path: filePath,
|
||||
old_string: oldLines.join('\n'),
|
||||
new_string: newLines.join('\n')
|
||||
}),
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: toolName,
|
||||
toolInput: input,
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output || ''
|
||||
});
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||
|
||||
const total = messages.length;
|
||||
|
||||
// Apply pagination if limit is specified
|
||||
if (limit !== null) {
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = messages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage
|
||||
};
|
||||
}
|
||||
|
||||
return { messages, tokenUsage };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCodexSession(sessionId) {
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
|
||||
const findJsonlFiles = async (dir) => {
|
||||
const files = [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await findJsonlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
return files;
|
||||
};
|
||||
|
||||
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
|
||||
|
||||
for (const filePath of jsonlFiles) {
|
||||
const sessionData = await parseCodexSessionFile(filePath);
|
||||
if (sessionData && sessionData.id === sessionId) {
|
||||
await fs.unlink(filePath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Codex session file not found for session ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getProjects,
|
||||
getSessions,
|
||||
@@ -1150,5 +1819,8 @@ export {
|
||||
loadProjectConfig,
|
||||
saveProjectConfig,
|
||||
extractProjectDirectory,
|
||||
clearProjectDirectoryCache
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessions,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession
|
||||
};
|
||||
|
||||
1231
server/routes/agent.js
Normal file
@@ -53,11 +53,11 @@ router.post('/register', async (req, res) => {
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
// Update last login (non-fatal, outside transaction)
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
|
||||
263
server/routes/cli-auth.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/claude/status', async (req, res) => {
|
||||
try {
|
||||
const credentialsResult = await checkClaudeCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cursor/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCursorStatus();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/codex/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCodexCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function checkClaudeCredentials() {
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCursorStatus() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: emailMatch[1],
|
||||
output: stdout
|
||||
});
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: 'Logged in',
|
||||
output: stdout
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Not logged in'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: stderr || 'Not logged in'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCodexCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
// Tokens are nested under 'tokens' key
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
// Check for valid tokens (id_token or access_token)
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
// Try to extract email from id_token JWT payload
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
// JWT is base64url encoded: header.payload.signature
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// If JWT decoding fails, use fallback
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
// Also check for OPENAI_API_KEY as fallback auth method
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Codex not configured'
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
344
server/routes/codex.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function createCliResponder(res) {
|
||||
let responded = false;
|
||||
return (status, payload) => {
|
||||
if (responded || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
res.status(status).json(payload);
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const content = await fs.readFile(configPath, 'utf8');
|
||||
const config = TOML.parse(content);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: config.model || null,
|
||||
mcpServers: config.mcp_servers || {},
|
||||
approvalMode: config.approval_mode || 'suggest'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: null,
|
||||
mcpServers: {},
|
||||
approvalMode: 'suggest'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Error reading Codex config:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId/messages', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
const result = await getCodexSessionMessages(
|
||||
sessionId,
|
||||
limit ? parseInt(limit, 10) : null,
|
||||
offset ? parseInt(offset, 10) : 0
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex session messages:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
await deleteCodexSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// MCP Server Management Routes
|
||||
|
||||
router.get('/mcp/cli/list', async (req, res) => {
|
||||
try {
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
||||
} else {
|
||||
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/mcp/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, command, args = [], env = {} } = req.body;
|
||||
|
||||
if (!name || !command) {
|
||||
return res.status(400).json({ error: 'name and command are required' });
|
||||
}
|
||||
|
||||
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
|
||||
let cliArgs = ['mcp', 'add', name];
|
||||
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
});
|
||||
|
||||
cliArgs.push('--', command);
|
||||
|
||||
if (args && args.length > 0) {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/mcp/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/cli/get/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
||||
} else {
|
||||
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/config/read', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
let configData = null;
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(configPath, 'utf8');
|
||||
configData = TOML.parse(fileContent);
|
||||
} catch (error) {
|
||||
// Config file doesn't exist
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({ success: true, configPath, servers: [] }); }
|
||||
|
||||
const servers = [];
|
||||
|
||||
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
|
||||
for (const [name, config] of Object.entries(configData.mcp_servers)) {
|
||||
servers.push({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'user',
|
||||
config: {
|
||||
command: config.command || '',
|
||||
args: config.args || [],
|
||||
env: config.env || {}
|
||||
},
|
||||
raw: config
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, configPath, servers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function parseCodexListOutput(output) {
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
servers.push({ name, type: 'stdio', status, description });
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
function parseCodexGetOutput(output) {
|
||||
try {
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
const server = { raw_output: output };
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
|
||||
}
|
||||
|
||||
return server;
|
||||
} catch (error) {
|
||||
return { raw_output: output, parse_error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
import matter from 'gray-matter';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -182,23 +183,15 @@ Custom commands can be created in:
|
||||
},
|
||||
|
||||
'/model': async (args, context) => {
|
||||
// Read available models from config or defaults
|
||||
// Read available models from centralized constants
|
||||
const availableModels = {
|
||||
claude: [
|
||||
'claude-sonnet-4.5',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4',
|
||||
'claude-sonnet-3.5'
|
||||
],
|
||||
cursor: [
|
||||
'gpt-5',
|
||||
'sonnet-4',
|
||||
'opus-4.1'
|
||||
]
|
||||
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
|
||||
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
|
||||
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
|
||||
};
|
||||
|
||||
const currentProvider = context?.provider || 'claude';
|
||||
const currentModel = context?.model || 'claude-sonnet-4.5';
|
||||
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
@@ -217,26 +210,64 @@ Custom commands can be created in:
|
||||
},
|
||||
|
||||
'/cost': async (args, context) => {
|
||||
// Calculate token usage and cost
|
||||
const sessionId = context?.sessionId;
|
||||
const tokenUsage = context?.tokenUsage || { used: 0, total: 200000 };
|
||||
const tokenUsage = context?.tokenUsage || {};
|
||||
const provider = context?.provider || 'claude';
|
||||
const model =
|
||||
context?.model ||
|
||||
(provider === 'cursor'
|
||||
? CURSOR_MODELS.DEFAULT
|
||||
: provider === 'codex'
|
||||
? CODEX_MODELS.DEFAULT
|
||||
: CLAUDE_MODELS.DEFAULT);
|
||||
|
||||
const costPerMillion = {
|
||||
'claude-sonnet-4.5': { input: 3, output: 15 },
|
||||
'claude-sonnet-4': { input: 3, output: 15 },
|
||||
'claude-opus-4': { input: 15, output: 75 },
|
||||
'gpt-5': { input: 5, output: 15 }
|
||||
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
|
||||
const total =
|
||||
Number(
|
||||
tokenUsage.total ??
|
||||
tokenUsage.contextWindow ??
|
||||
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
|
||||
) || 160000;
|
||||
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
||||
|
||||
const inputTokensRaw =
|
||||
Number(
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.promptTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const outputTokens =
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
tokenUsage.output ??
|
||||
tokenUsage.cumulativeOutputTokens ??
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const cacheTokens =
|
||||
Number(
|
||||
tokenUsage.cacheReadTokens ??
|
||||
tokenUsage.cacheCreationTokens ??
|
||||
tokenUsage.cacheTokens ??
|
||||
tokenUsage.cachedTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
// If we only have total used tokens, treat them as input for display/estimation.
|
||||
const inputTokens =
|
||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
|
||||
|
||||
// Rough default rates by provider (USD / 1M tokens).
|
||||
const pricingByProvider = {
|
||||
claude: { input: 3, output: 15 },
|
||||
cursor: { input: 3, output: 15 },
|
||||
codex: { input: 1.5, output: 6 },
|
||||
};
|
||||
const rates = pricingByProvider[provider] || pricingByProvider.claude;
|
||||
|
||||
const model = context?.model || 'claude-sonnet-4.5';
|
||||
const rates = costPerMillion[model] || costPerMillion['claude-sonnet-4.5'];
|
||||
|
||||
// Estimate 70% input, 30% output
|
||||
const estimatedInputTokens = Math.floor(tokenUsage.used * 0.7);
|
||||
const estimatedOutputTokens = Math.floor(tokenUsage.used * 0.3);
|
||||
|
||||
const inputCost = (estimatedInputTokens / 1000000) * rates.input;
|
||||
const outputCost = (estimatedOutputTokens / 1000000) * rates.output;
|
||||
const inputCost = (inputTokens / 1_000_000) * rates.input;
|
||||
const outputCost = (outputTokens / 1_000_000) * rates.output;
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
return {
|
||||
@@ -244,19 +275,17 @@ Custom commands can be created in:
|
||||
action: 'cost',
|
||||
data: {
|
||||
tokenUsage: {
|
||||
used: tokenUsage.used,
|
||||
total: tokenUsage.total,
|
||||
percentage: ((tokenUsage.used / tokenUsage.total) * 100).toFixed(1)
|
||||
used,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
currency: 'USD'
|
||||
},
|
||||
model,
|
||||
rates
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -33,7 +34,7 @@ router.get('/config', async (req, res) => {
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: "gpt-5",
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: "GPT-5"
|
||||
},
|
||||
permissions: {
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||
error.code = code;
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
async function getActualProjectPath(projectName) {
|
||||
try {
|
||||
@@ -19,6 +58,35 @@ async function getActualProjectPath(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to strip git diff headers
|
||||
function stripDiffHeaders(diff) {
|
||||
if (!diff) return '';
|
||||
|
||||
const lines = diff.split('\n');
|
||||
const filteredLines = [];
|
||||
let startIncluding = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
|
||||
if (line.startsWith('diff --git') ||
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('new file mode') ||
|
||||
line.startsWith('deleted file mode') ||
|
||||
line.startsWith('---') ||
|
||||
line.startsWith('+++')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start including lines from @@ hunk headers onwards
|
||||
if (line.startsWith('@@') || startIncluding) {
|
||||
startIncluding = true;
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredLines.join('\n');
|
||||
}
|
||||
|
||||
// Helper function to validate git repository
|
||||
async function validateGitRepository(projectPath) {
|
||||
try {
|
||||
@@ -29,19 +97,16 @@ async function validateGitRepository(projectPath) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use --show-toplevel to get the root of the git repository
|
||||
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
const normalizedGitRoot = path.resolve(gitRoot.trim());
|
||||
const normalizedProjectPath = path.resolve(projectPath);
|
||||
|
||||
// Ensure the git root matches our project path (prevent using parent git repos)
|
||||
if (normalizedGitRoot !== normalizedProjectPath) {
|
||||
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('Project directory is not a git repository')) {
|
||||
throw error;
|
||||
// Allow any directory that is inside a work tree (repo root or nested folder).
|
||||
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
|
||||
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
||||
if (!isInsideWorkTree) {
|
||||
throw new Error('Not inside a git work tree');
|
||||
}
|
||||
|
||||
// Ensure git can resolve the repository root for this directory.
|
||||
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
} catch {
|
||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||
}
|
||||
}
|
||||
@@ -49,34 +114,47 @@ async function validateGitRepository(projectPath) {
|
||||
// Get git status for a project
|
||||
router.get('/status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
|
||||
// Get current branch - handle case where there are no commits yet
|
||||
let branch = 'main';
|
||||
let hasCommits = true;
|
||||
try {
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
branch = branchOutput.trim();
|
||||
} catch (error) {
|
||||
// No HEAD exists - repository has no commits yet
|
||||
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
||||
hasCommits = false;
|
||||
branch = 'main';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
|
||||
|
||||
|
||||
const modified = [];
|
||||
const added = [];
|
||||
const deleted = [];
|
||||
const untracked = [];
|
||||
|
||||
|
||||
statusOutput.split('\n').forEach(line => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
|
||||
const status = line.substring(0, 2);
|
||||
const file = line.substring(3);
|
||||
|
||||
|
||||
if (status === 'M ' || status === ' M' || status === 'MM') {
|
||||
modified.push(file);
|
||||
} else if (status === 'A ' || status === 'AM') {
|
||||
@@ -87,9 +165,10 @@ router.get('/status', async (req, res) => {
|
||||
untracked.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
branch: branch.trim(),
|
||||
branch,
|
||||
hasCommits,
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
@@ -97,9 +176,9 @@ router.get('/status', async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git status error:', error);
|
||||
res.json({
|
||||
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
res.json({
|
||||
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
: 'Git operation failed',
|
||||
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
@@ -122,32 +201,47 @@ router.get('/diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if file is untracked
|
||||
// Check if file is untracked or deleted
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
let diff;
|
||||
if (isUntracked) {
|
||||
// For untracked files, show the entire file content as additions
|
||||
const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// For directories, show a simple message
|
||||
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
||||
} else {
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||
lines.map(line => `+${line}`).join('\n');
|
||||
}
|
||||
} else if (isDeleted) {
|
||||
// For deleted files, show the entire file content from HEAD as deletions
|
||||
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||
lines.map(line => `+${line}`).join('\n');
|
||||
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||
lines.map(line => `-${line}`).join('\n');
|
||||
} else {
|
||||
// Get diff for tracked files
|
||||
// First check for unstaged changes (working tree vs index)
|
||||
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
|
||||
|
||||
|
||||
if (unstagedDiff) {
|
||||
// Show unstaged changes if they exist
|
||||
diff = unstagedDiff;
|
||||
diff = stripDiffHeaders(unstagedDiff);
|
||||
} else {
|
||||
// If no unstaged changes, check for staged changes (index vs HEAD)
|
||||
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
|
||||
diff = stagedDiff || '';
|
||||
diff = stripDiffHeaders(stagedDiff) || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
res.json({ diff });
|
||||
} catch (error) {
|
||||
console.error('Git diff error:', error);
|
||||
@@ -155,6 +249,113 @@ router.get('/diff', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get file content with diff information for CodeEditor
|
||||
router.get('/file-with-diff', async (req, res) => {
|
||||
const { project, file } = req.query;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check file status
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
let currentContent = '';
|
||||
let oldContent = '';
|
||||
|
||||
if (isDeleted) {
|
||||
// For deleted files, get content from HEAD
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
oldContent = headContent;
|
||||
currentContent = headContent; // Show the deleted content in editor
|
||||
} else {
|
||||
// Get current file content
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Cannot show content for directories
|
||||
return res.status(400).json({ error: 'Cannot show diff for directories' });
|
||||
}
|
||||
|
||||
currentContent = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
if (!isUntracked) {
|
||||
// Get the old content from HEAD for tracked files
|
||||
try {
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
oldContent = headContent;
|
||||
} catch (error) {
|
||||
// File might be newly added to git (staged but not committed)
|
||||
oldContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
currentContent,
|
||||
oldContent,
|
||||
isDeleted,
|
||||
isUntracked
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git file-with-diff error:', error);
|
||||
res.json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create initial commit
|
||||
router.post('/initial-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if there are already commits
|
||||
try {
|
||||
await execAsync('git rev-parse HEAD', { cwd: projectPath });
|
||||
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
||||
} catch (error) {
|
||||
// No HEAD - this is good, we can create initial commit
|
||||
}
|
||||
|
||||
// Add all files
|
||||
await execAsync('git add .', { cwd: projectPath });
|
||||
|
||||
// Create initial commit
|
||||
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Git initial commit error:', error);
|
||||
|
||||
// Handle the case where there's nothing to commit
|
||||
if (error.message.includes('nothing to commit')) {
|
||||
return res.status(400).json({
|
||||
error: 'Nothing to commit',
|
||||
details: 'No files found in the repository. Add some files first.'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Commit changes
|
||||
router.post('/commit', async (req, res) => {
|
||||
const { project, message, files } = req.body;
|
||||
@@ -278,11 +479,17 @@ router.get('/commits', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const parsedLimit = Number.parseInt(String(limit), 10);
|
||||
const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||
? Math.min(parsedLimit, 100)
|
||||
: 10;
|
||||
|
||||
// Get commit log with stats
|
||||
const { stdout } = await execAsync(
|
||||
`git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`,
|
||||
{ cwd: projectPath }
|
||||
const { stdout } = await spawnAsync(
|
||||
'git',
|
||||
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
|
||||
{ cwd: projectPath },
|
||||
);
|
||||
|
||||
const commits = stdout
|
||||
@@ -343,19 +550,24 @@ router.get('/commit-diff', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate commit message based on staged changes
|
||||
// Generate commit message based on staged changes using AI
|
||||
router.post('/generate-commit-message', async (req, res) => {
|
||||
const { project, files } = req.body;
|
||||
|
||||
const { project, files, provider = 'claude' } = req.body;
|
||||
|
||||
if (!project || !files || files.length === 0) {
|
||||
return res.status(400).json({ error: 'Project name and files are required' });
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
if (!['claude', 'cursor'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
|
||||
// Get diff for selected files
|
||||
let combinedDiff = '';
|
||||
let diffContext = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
@@ -363,17 +575,36 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
if (stdout) {
|
||||
combinedDiff += `\n--- ${file} ---\n${stdout}`;
|
||||
diffContext += `\n--- ${file} ---\n${stdout}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting diff for ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use AI to generate commit message (simple implementation)
|
||||
// In a real implementation, you might want to use GPT or Claude API
|
||||
const message = generateSimpleCommitMessage(files, combinedDiff);
|
||||
|
||||
|
||||
// If no diff found, might be untracked files
|
||||
if (!diffContext.trim()) {
|
||||
// Try to get content of untracked files
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
} else {
|
||||
diffContext += `\n--- ${file} (new directory) ---\n`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate commit message using AI
|
||||
const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
|
||||
|
||||
res.json({ message });
|
||||
} catch (error) {
|
||||
console.error('Generate commit message error:', error);
|
||||
@@ -381,46 +612,145 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Simple commit message generator (can be replaced with AI)
|
||||
function generateSimpleCommitMessage(files, diff) {
|
||||
const fileCount = files.length;
|
||||
const isMultipleFiles = fileCount > 1;
|
||||
|
||||
// Analyze the diff to determine the type of change
|
||||
const additions = (diff.match(/^\+[^+]/gm) || []).length;
|
||||
const deletions = (diff.match(/^-[^-]/gm) || []).length;
|
||||
|
||||
// Determine the primary action
|
||||
let action = 'Update';
|
||||
if (additions > 0 && deletions === 0) {
|
||||
action = 'Add';
|
||||
} else if (deletions > 0 && additions === 0) {
|
||||
action = 'Remove';
|
||||
} else if (additions > deletions * 2) {
|
||||
action = 'Enhance';
|
||||
} else if (deletions > additions * 2) {
|
||||
action = 'Refactor';
|
||||
}
|
||||
|
||||
// Generate message based on files
|
||||
if (isMultipleFiles) {
|
||||
const components = new Set(files.map(f => {
|
||||
const parts = f.split('/');
|
||||
return parts[parts.length - 2] || parts[0];
|
||||
}));
|
||||
|
||||
if (components.size === 1) {
|
||||
return `${action} ${[...components][0]} component`;
|
||||
} else {
|
||||
return `${action} multiple components`;
|
||||
/**
|
||||
* Generates a commit message using AI (Claude SDK or Cursor CLI)
|
||||
* @param {Array<string>} files - List of changed files
|
||||
* @param {string} diffContext - Git diff content
|
||||
* @param {string} provider - 'claude' or 'cursor'
|
||||
* @param {string} projectPath - Project directory path
|
||||
* @returns {Promise<string>} Generated commit message
|
||||
*/
|
||||
async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
|
||||
// Create the prompt
|
||||
const prompt = `Generate a conventional commit message for these changes.
|
||||
|
||||
REQUIREMENTS:
|
||||
- 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')}
|
||||
|
||||
DIFFS:
|
||||
${diffContext.substring(0, 4000)}
|
||||
|
||||
Generate the commit message:`;
|
||||
|
||||
try {
|
||||
// Create a simple writer that collects the response
|
||||
let responseText = '';
|
||||
const writer = {
|
||||
send: (data) => {
|
||||
try {
|
||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
console.log('🔍 Writer received message type:', parsed.type);
|
||||
|
||||
// Handle different message formats from Claude SDK and Cursor CLI
|
||||
// Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
|
||||
if (parsed.type === 'claude-response' && parsed.data) {
|
||||
const message = parsed.data.message || parsed.data;
|
||||
console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
|
||||
if (message.content && Array.isArray(message.content)) {
|
||||
// Extract text from content array
|
||||
for (const item of message.content) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
|
||||
responseText += item.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cursor CLI sends: {type: 'cursor-output', output: '...'}
|
||||
else if (parsed.type === 'cursor-output' && parsed.output) {
|
||||
console.log('✅ Cursor output:', parsed.output.substring(0, 100));
|
||||
responseText += parsed.output;
|
||||
}
|
||||
// Also handle direct text messages
|
||||
else if (parsed.type === 'text' && parsed.text) {
|
||||
console.log('✅ Direct text:', parsed.text.substring(0, 100));
|
||||
responseText += parsed.text;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
console.error('Error parsing writer data:', e);
|
||||
}
|
||||
},
|
||||
setSessionId: () => {}, // No-op for this use case
|
||||
};
|
||||
|
||||
console.log('🚀 Calling AI agent with provider:', provider);
|
||||
console.log('📝 Prompt length:', prompt.length);
|
||||
|
||||
// Call the appropriate agent
|
||||
if (provider === 'claude') {
|
||||
await queryClaudeSDK(prompt, {
|
||||
cwd: projectPath,
|
||||
permissionMode: 'bypassPermissions',
|
||||
model: 'sonnet'
|
||||
}, writer);
|
||||
} else if (provider === 'cursor') {
|
||||
await spawnCursor(prompt, {
|
||||
cwd: projectPath,
|
||||
skipPermissions: true
|
||||
}, writer);
|
||||
}
|
||||
} else {
|
||||
const fileName = files[0].split('/').pop();
|
||||
const componentName = fileName.replace(/\.(jsx?|tsx?|css|scss)$/, '');
|
||||
return `${action} ${componentName}`;
|
||||
|
||||
console.log('📊 Total response text collected:', responseText.length, 'characters');
|
||||
console.log('📄 Response preview:', responseText.substring(0, 200));
|
||||
|
||||
// Clean up the response
|
||||
const cleanedMessage = cleanCommitMessage(responseText);
|
||||
console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
|
||||
|
||||
return cleanedMessage || 'chore: update files';
|
||||
} catch (error) {
|
||||
console.error('Error generating commit message with AI:', error);
|
||||
// Fallback to simple message
|
||||
return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
|
||||
* @param {string} text - Raw AI response
|
||||
* @returns {string} Clean commit message
|
||||
*/
|
||||
function cleanCommitMessage(text) {
|
||||
if (!text || !text.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let cleaned = text.trim();
|
||||
|
||||
// Remove markdown code blocks
|
||||
cleaned = cleaned.replace(/```[a-z]*\n/g, '');
|
||||
cleaned = cleaned.replace(/```/g, '');
|
||||
|
||||
// Remove markdown headers
|
||||
cleaned = cleaned.replace(/^#+\s*/gm, '');
|
||||
|
||||
// Remove leading/trailing quotes
|
||||
cleaned = cleaned.replace(/^["']|["']$/g, '');
|
||||
|
||||
// If there are multiple lines, take everything (subject + body)
|
||||
// Just clean up extra blank lines
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Remove any explanatory text before the actual commit message
|
||||
// Look for conventional commit pattern and start from there
|
||||
const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
|
||||
if (conventionalCommitMatch) {
|
||||
cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
// Get remote status (ahead/behind commits with smart remote detection)
|
||||
router.get('/remote-status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
@@ -766,10 +1096,17 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
|
||||
const status = statusOutput.substring(0, 2);
|
||||
|
||||
|
||||
if (status === '??') {
|
||||
// Untracked file - delete it
|
||||
await fs.unlink(path.join(projectPath, file));
|
||||
// Untracked file or directory - delete it
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} else if (status.includes('M') || status.includes('D')) {
|
||||
// Modified or deleted file - restore from HEAD
|
||||
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||
@@ -810,14 +1147,22 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
||||
}
|
||||
|
||||
// Delete the untracked file
|
||||
await fs.unlink(path.join(projectPath, file));
|
||||
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
// Delete the untracked file or directory
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Use rm with recursive option for directories
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Git delete untracked error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
550
server/routes/projects.js
Normal file
@@ -0,0 +1,550 @@
|
||||
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();
|
||||
|
||||
function sanitizeGitError(message, token) {
|
||||
if (!message || !token) return message;
|
||||
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
||||
}
|
||||
|
||||
// Configure allowed workspace root (defaults to user's home directory)
|
||||
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
export const FORBIDDEN_PATHS = [
|
||||
// Unix
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
'/usr',
|
||||
'/dev',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/var',
|
||||
'/boot',
|
||||
'/root',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run',
|
||||
// Windows
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData',
|
||||
'C:\\System Volume Information',
|
||||
'C:\\$Recycle.Bin'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates that a path is safe for workspace operations
|
||||
* @param {string} requestedPath - The path to validate
|
||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||
*/
|
||||
export 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') {
|
||||
// Create the directory if it doesn't exist
|
||||
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;
|
||||
}
|
||||
|
||||
// Extract repo name from URL for the clone destination
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await fs.access(clonePath);
|
||||
return res.status(409).json({
|
||||
error: 'Directory already exists',
|
||||
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
|
||||
});
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Clone the repository into a subfolder
|
||||
try {
|
||||
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
|
||||
} catch (error) {
|
||||
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
||||
try {
|
||||
const stats = await fs.stat(clonePath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
// Directory doesn't exist or cleanup failed - ignore
|
||||
}
|
||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||
}
|
||||
|
||||
// Add the cloned repo path to the project list
|
||||
const project = await addProjectManually(clonePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'New workspace created and repository cloned successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new workspace to the project list (no clone)
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: '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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone repository with progress streaming (SSE)
|
||||
* GET /api/projects/clone-progress
|
||||
*/
|
||||
router.get('/clone-progress', async (req, res) => {
|
||||
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendEvent = (type, data) => {
|
||||
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
if (!workspacePath || !githubUrl) {
|
||||
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = await validateWorkspacePath(workspacePath);
|
||||
if (!validation.valid) {
|
||||
sendEvent('error', { message: validation.error });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
let githubToken = null;
|
||||
if (githubTokenId) {
|
||||
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
||||
if (!token) {
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
sendEvent('error', { message: 'GitHub token not found' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
githubToken = token.github_token;
|
||||
} else if (newGithubToken) {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await fs.access(clonePath);
|
||||
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
|
||||
res.end();
|
||||
return;
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
let cloneUrl = githubUrl;
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
// SSH URL or invalid - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
let lastError = '';
|
||||
|
||||
gitProcess.stdout.on('data', (data) => {
|
||||
const message = data.toString().trim();
|
||||
if (message) {
|
||||
sendEvent('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.stderr.on('data', (data) => {
|
||||
const message = data.toString().trim();
|
||||
lastError = message;
|
||||
if (message) {
|
||||
sendEvent('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('close', async (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const project = await addProjectManually(clonePath);
|
||||
sendEvent('complete', { project, message: 'Repository cloned successfully' });
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
|
||||
}
|
||||
} else {
|
||||
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||
let errorMessage = 'Git clone failed';
|
||||
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
||||
errorMessage = 'Authentication failed. Please check your credentials.';
|
||||
} else if (lastError.includes('Repository not found')) {
|
||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||
} else if (lastError.includes('already exists')) {
|
||||
errorMessage = 'Directory already exists';
|
||||
} else if (sanitizedError) {
|
||||
errorMessage = sanitizedError;
|
||||
}
|
||||
try {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
|
||||
}
|
||||
sendEvent('error', { message: errorMessage });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
sendEvent('error', { message: 'Git is not installed or not in PATH' });
|
||||
} else {
|
||||
sendEvent('error', { message: error.message });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
gitProcess.kill();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: error.message });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to clone a GitHub repository
|
||||
*/
|
||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cloneUrl = githubUrl;
|
||||
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
// SSH URL - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
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;
|
||||
178
server/routes/settings.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import express from 'express';
|
||||
import { apiKeysDb, credentialsDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ===============================
|
||||
// API Keys Management
|
||||
// ===============================
|
||||
|
||||
// Get all API keys for the authenticated user
|
||||
router.get('/api-keys', async (req, res) => {
|
||||
try {
|
||||
const apiKeys = apiKeysDb.getApiKeys(req.user.id);
|
||||
// Don't send the full API key in the list for security
|
||||
const sanitizedKeys = apiKeys.map(key => ({
|
||||
...key,
|
||||
api_key: key.api_key.substring(0, 10) + '...'
|
||||
}));
|
||||
res.json({ apiKeys: sanitizedKeys });
|
||||
} catch (error) {
|
||||
console.error('Error fetching API keys:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch API keys' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new API key
|
||||
router.post('/api-keys', async (req, res) => {
|
||||
try {
|
||||
const { keyName } = req.body;
|
||||
|
||||
if (!keyName || !keyName.trim()) {
|
||||
return res.status(400).json({ error: 'Key name is required' });
|
||||
}
|
||||
|
||||
const result = apiKeysDb.createApiKey(req.user.id, keyName.trim());
|
||||
res.json({
|
||||
success: true,
|
||||
apiKey: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
res.status(500).json({ error: 'Failed to create API key' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete an API key
|
||||
router.delete('/api-keys/:keyId', async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const success = apiKeysDb.deleteApiKey(req.user.id, parseInt(keyId));
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'API key not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
res.status(500).json({ error: 'Failed to delete API key' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle API key active status
|
||||
router.patch('/api-keys/:keyId/toggle', async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const { isActive } = req.body;
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return res.status(400).json({ error: 'isActive must be a boolean' });
|
||||
}
|
||||
|
||||
const success = apiKeysDb.toggleApiKey(req.user.id, parseInt(keyId), isActive);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'API key not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling API key:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle API key' });
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Generic Credentials Management
|
||||
// ===============================
|
||||
|
||||
// Get all credentials for the authenticated user (optionally filtered by type)
|
||||
router.get('/credentials', async (req, res) => {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
const credentials = credentialsDb.getCredentials(req.user.id, type || null);
|
||||
// Don't send the actual credential values for security
|
||||
res.json({ credentials });
|
||||
} catch (error) {
|
||||
console.error('Error fetching credentials:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new credential
|
||||
router.post('/credentials', async (req, res) => {
|
||||
try {
|
||||
const { credentialName, credentialType, credentialValue, description } = req.body;
|
||||
|
||||
if (!credentialName || !credentialName.trim()) {
|
||||
return res.status(400).json({ error: 'Credential name is required' });
|
||||
}
|
||||
|
||||
if (!credentialType || !credentialType.trim()) {
|
||||
return res.status(400).json({ error: 'Credential type is required' });
|
||||
}
|
||||
|
||||
if (!credentialValue || !credentialValue.trim()) {
|
||||
return res.status(400).json({ error: 'Credential value is required' });
|
||||
}
|
||||
|
||||
const result = credentialsDb.createCredential(
|
||||
req.user.id,
|
||||
credentialName.trim(),
|
||||
credentialType.trim(),
|
||||
credentialValue.trim(),
|
||||
description?.trim() || null
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
credential: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating credential:', error);
|
||||
res.status(500).json({ error: 'Failed to create credential' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a credential
|
||||
router.delete('/credentials/:credentialId', async (req, res) => {
|
||||
try {
|
||||
const { credentialId } = req.params;
|
||||
const success = credentialsDb.deleteCredential(req.user.id, parseInt(credentialId));
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Credential not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting credential:', error);
|
||||
res.status(500).json({ error: 'Failed to delete credential' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle credential active status
|
||||
router.patch('/credentials/:credentialId/toggle', async (req, res) => {
|
||||
try {
|
||||
const { credentialId } = req.params;
|
||||
const { isActive } = req.body;
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return res.status(400).json({ error: 'isActive must be a boolean' });
|
||||
}
|
||||
|
||||
const success = credentialsDb.toggleCredential(req.user.id, parseInt(credentialId), isActive);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Credential not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling credential:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle credential' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -331,15 +331,6 @@ router.get('/detect/:projectName', async (req, res) => {
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Broadcast TaskMaster project update via WebSocket
|
||||
if (req.app.locals.wss) {
|
||||
broadcastTaskMasterProjectUpdate(
|
||||
req.app.locals.wss,
|
||||
projectName,
|
||||
taskMasterResult
|
||||
);
|
||||
}
|
||||
|
||||
res.json(responseData);
|
||||
|
||||
} catch (error) {
|
||||
@@ -537,7 +528,8 @@ router.get('/next/:projectName', async (req, res) => {
|
||||
console.warn('Failed to execute task-master CLI:', cliError.message);
|
||||
|
||||
// Fallback to loading tasks and finding next one locally
|
||||
const tasksResponse = await fetch(`${req.protocol}://${req.get('host')}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
||||
// Use localhost to bypass proxy for internal server-to-server calls
|
||||
const tasksResponse = await fetch(`http://localhost:${process.env.PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
||||
headers: {
|
||||
'Authorization': req.headers.authorization
|
||||
}
|
||||
|
||||
106
server/routes/user.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import express from 'express';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/git-config', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
let gitConfig = userDb.getGitConfig(userId);
|
||||
|
||||
// If database is empty, try to get from system git config
|
||||
if (!gitConfig || (!gitConfig.git_name && !gitConfig.git_email)) {
|
||||
const systemConfig = await getSystemGitConfig();
|
||||
|
||||
// If system has values, save them to database for this user
|
||||
if (systemConfig.git_name || systemConfig.git_email) {
|
||||
userDb.updateGitConfig(userId, systemConfig.git_name, systemConfig.git_email);
|
||||
gitConfig = systemConfig;
|
||||
console.log(`Auto-populated git config from system for user ${userId}: ${systemConfig.git_name} <${systemConfig.git_email}>`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
gitName: gitConfig?.git_name || null,
|
||||
gitEmail: gitConfig?.git_email || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting git config:', error);
|
||||
res.status(500).json({ error: 'Failed to get git configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply git config globally via git config --global
|
||||
router.post('/git-config', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { gitName, gitEmail } = req.body;
|
||||
|
||||
if (!gitName || !gitEmail) {
|
||||
return res.status(400).json({ error: 'Git name and email are required' });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(gitEmail)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
userDb.updateGitConfig(userId, gitName, gitEmail);
|
||||
|
||||
try {
|
||||
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
|
||||
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
|
||||
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
||||
} catch (gitError) {
|
||||
console.error('Error applying git config:', gitError);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
gitName,
|
||||
gitEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating git config:', error);
|
||||
res.status(500).json({ error: 'Failed to update git configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/complete-onboarding', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
userDb.completeOnboarding(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Onboarding completed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
res.status(500).json({ error: 'Failed to complete onboarding' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/onboarding-status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const hasCompleted = userDb.hasCompletedOnboarding(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasCompletedOnboarding: hasCompleted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking onboarding status:', error);
|
||||
res.status(500).json({ error: 'Failed to check onboarding status' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
24
server/utils/gitConfig.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Read git configuration from system's global git config
|
||||
* @returns {Promise<{git_name: string|null, git_email: string|null}>}
|
||||
*/
|
||||
export async function getSystemGitConfig() {
|
||||
try {
|
||||
const [nameResult, emailResult] = await Promise.all([
|
||||
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
|
||||
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
|
||||
]);
|
||||
|
||||
return {
|
||||
git_name: nameResult.stdout.trim() || null,
|
||||
git_email: emailResult.stdout.trim() || null
|
||||
};
|
||||
} catch (error) {
|
||||
return { git_name: null, git_email: null };
|
||||
}
|
||||
}
|
||||
67
shared/modelConstants.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Centralized Model Definitions
|
||||
* Single source of truth for all supported AI models
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*
|
||||
* Note: Claude uses two different formats:
|
||||
* - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js
|
||||
* - API format ('claude-sonnet-4.5') - used by slash commands for display
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
// Models in SDK format (what the actual SDK accepts)
|
||||
OPTIONS: [
|
||||
{ value: 'sonnet', label: 'Sonnet' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'haiku', label: 'Haiku' },
|
||||
{ value: 'opusplan', label: 'Opus Plan' },
|
||||
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
|
||||
],
|
||||
|
||||
DEFAULT: 'sonnet'
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
|
||||
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
||||
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
|
||||
{ value: 'composer-1', label: 'Composer 1' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
|
||||
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
|
||||
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
|
||||
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
|
||||
{ value: 'grok', label: 'Grok' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5'
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Models
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'o3', label: 'O3' },
|
||||
{ value: 'o4-mini', label: 'O4-mini' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5.3-codex'
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
# Slash Command Execution Fix - Progress Report
|
||||
|
||||
## Issue
|
||||
Slash commands weren't executing when selected from the command menu. After typing a command like `/tm:list` and selecting it from the menu, nothing would happen - the page would stay on "Choose Your AI Assistant" screen.
|
||||
|
||||
## Root Cause
|
||||
The `handleCustomCommand` function was trying to call `handleSubmit` via a ref, but the ref wasn't being set properly. Originally attempted to set the ref inside `handleSubmit` itself, which meant it was only set AFTER the first submit - too late for command execution.
|
||||
|
||||
## Solution Implemented
|
||||
1. Converted `handleSubmit` to use `useCallback` with proper dependencies
|
||||
2. Added a `useEffect` hook that runs after `handleSubmit` is defined to store it in `handleSubmitRef`
|
||||
3. Now `handleCustomCommand` can access `handleSubmit` via the ref and call it with a fake event
|
||||
|
||||
## Code Changes
|
||||
|
||||
### File: src/components/ChatInterface.jsx
|
||||
|
||||
**Added ref declaration (around line 1534):**
|
||||
```javascript
|
||||
// Ref to store handleSubmit so we can call it from handleCustomCommand
|
||||
const handleSubmitRef = useRef(null);
|
||||
```
|
||||
|
||||
**Modified handleCustomCommand (around line 1555):**
|
||||
```javascript
|
||||
// Set the input to the command content
|
||||
setInput(content);
|
||||
|
||||
// Wait for state to update, then directly call handleSubmit
|
||||
setTimeout(() => {
|
||||
if (handleSubmitRef.current) {
|
||||
// Create a fake event to pass to handleSubmit
|
||||
const fakeEvent = { preventDefault: () => {} };
|
||||
handleSubmitRef.current(fakeEvent);
|
||||
}
|
||||
}, 50);
|
||||
```
|
||||
|
||||
**Converted handleSubmit to useCallback (line 3292):**
|
||||
```javascript
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading || !selectedProject) return;
|
||||
// ... rest of function
|
||||
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
|
||||
```
|
||||
|
||||
**Added useEffect to store ref (line 3437):**
|
||||
```javascript
|
||||
// Store handleSubmit in ref so handleCustomCommand can access it
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
```
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### 1. Commands Button Visibility ✅ FIXED
|
||||
- **Problem**: Button was not showing in active chat sessions with provider selected
|
||||
- **Root Cause**: Button was positioned at `right-14 sm:right-16` which overlapped with the clear button at `sm:right-28`
|
||||
- **Solution**: Changed button position to `right-14 sm:right-36` to place it left of the clear button
|
||||
- **File**: src/components/ChatInterface.jsx:4255
|
||||
- **Status**: Fixed in build dist/assets/index-CWRjcZ7A.js
|
||||
|
||||
### 2. Slash Command Menu Positioning ✅ FIXED
|
||||
- **Problem**: Mobile positioning was inconsistent - used wrong ref for bottom calculation
|
||||
- **Root Cause**: Position calculation used `inputContainerRef` (permission mode selector) instead of `textareaRef` (actual input)
|
||||
- **Solution**:
|
||||
- Changed bottom calculation to use `textareaRef` instead of `inputContainerRef`
|
||||
- Updated formula: `window.innerHeight - textareaRef.getBoundingClientRect().top + 8`
|
||||
- Removed extra `+ 8` in CommandMenu.jsx since spacing is already in the calculation
|
||||
- Added explicit `maxHeight: '300px'` to desktop positioning for consistency
|
||||
- Mobile maxHeight now uses `min(50vh, 300px)` for better consistency
|
||||
- **Files Modified**:
|
||||
- src/components/ChatInterface.jsx:4132-4134 - Fixed bottom position calculation
|
||||
- src/components/CommandMenu.jsx:30-46 - Improved positioning logic and max heights
|
||||
|
||||
## Related Issues Found (Not Fixed Yet)
|
||||
|
||||
### 3. Service Worker Caching Issue
|
||||
- After building, the service worker caches old build files
|
||||
- Requires manual unregistration of service worker on first load after build
|
||||
- Causes 404 errors for old asset filenames (e.g., index-n_2V3_vw.js when new build has index-Wp3pq386.js)
|
||||
- Need to implement proper cache busting or service worker update strategy
|
||||
|
||||
### 4. Chat Screen Jumping
|
||||
- Screen jumps/scrolls when Task Master widget appears/disappears
|
||||
- Likely due to layout shifts from the task widget
|
||||
|
||||
## Testing Status
|
||||
- ✅ Slash command execution fix implemented and built
|
||||
- ✅ Commands button visibility fix implemented and built
|
||||
- ⏳ Not yet tested end-to-end due to service worker caching issues requiring manual cache clearing
|
||||
- Need to test:
|
||||
1. Verify commands button is now visible to the left of clear button
|
||||
2. Click commands button to open menu
|
||||
3. Type `/tm:list` in chat input
|
||||
4. Select command from menu
|
||||
5. Verify command content loads and sends to Claude
|
||||
6. Verify session is created if none exists
|
||||
|
||||
## Next Steps
|
||||
1. Test the slash command button visibility fix
|
||||
2. Test the slash command execution fix end-to-end
|
||||
3. Fix service worker caching to enable easier testing
|
||||
4. Fix chat screen jumping issue
|
||||
|
||||
## Build Info
|
||||
- Latest build: dist/assets/index-C5zDTo8x.js (657.55 kB)
|
||||
- Commands button positioned at `right-14 sm:right-36` (mobile/desktop)
|
||||
- Menu positioning uses `textareaRef` for accurate placement
|
||||
- Mobile menu: `bottom` calculated from textarea top + 8px spacing
|
||||
- Desktop menu: `top` calculated with 316px offset, max 300px height
|
||||
- Server running on port 3001
|
||||
- Using Claude Agents SDK for Claude integration
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Mobile Positioning
|
||||
```javascript
|
||||
// ChatInterface.jsx - Position calculation
|
||||
bottom: textareaRef.current
|
||||
? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8
|
||||
: 90
|
||||
|
||||
// CommandMenu.jsx - Mobile layout
|
||||
{
|
||||
position: 'fixed',
|
||||
bottom: `${inputBottom}px`,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
maxHeight: 'min(50vh, 300px)'
|
||||
}
|
||||
```
|
||||
|
||||
### Desktop Positioning
|
||||
```javascript
|
||||
// ChatInterface.jsx - Position calculation
|
||||
top: textareaRef.current
|
||||
? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316)
|
||||
: 0
|
||||
|
||||
// CommandMenu.jsx - Desktop layout
|
||||
{
|
||||
position: 'fixed',
|
||||
top: `${calculatedTop}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxHeight: '300px'
|
||||
}
|
||||
```
|
||||
802
src/App.jsx
@@ -1,802 +0,0 @@
|
||||
/*
|
||||
* App.jsx - Main Application Component with Session Protection System
|
||||
*
|
||||
* SESSION PROTECTION SYSTEM OVERVIEW:
|
||||
* ===================================
|
||||
*
|
||||
* Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages
|
||||
* during active conversations, creating a poor user experience.
|
||||
*
|
||||
* Solution: Track "active sessions" and pause project updates during conversations.
|
||||
*
|
||||
* How it works:
|
||||
* 1. When user sends message → session marked as "active"
|
||||
* 2. Project updates are skipped while session is active
|
||||
* 3. When conversation completes/aborts → session marked as "inactive"
|
||||
* 4. Project updates resume normally
|
||||
*
|
||||
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MainContent from './components/MainContent';
|
||||
import MobileNav from './components/MobileNav';
|
||||
import Settings from './components/Settings';
|
||||
import QuickSettingsPanel from './components/QuickSettingsPanel';
|
||||
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import useLocalStorage from './hooks/useLocalStorage';
|
||||
import { api, authenticatedFetch } from './utils/api';
|
||||
|
||||
|
||||
// Main App component with routing
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams();
|
||||
|
||||
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [selectedSession, setSelectedSession] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files'
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
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);
|
||||
// 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
|
||||
// until the conversation completes or is aborted.
|
||||
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
|
||||
|
||||
// Processing Sessions: Track which sessions are currently thinking/processing
|
||||
// This allows us to restore the "Thinking..." banner when switching back to a processing session
|
||||
const [processingSessions, setProcessingSessions] = useState(new Set());
|
||||
|
||||
// External Message Update Trigger: Incremented when external CLI modifies current session's JSONL
|
||||
// Triggers ChatInterface to reload messages without switching sessions
|
||||
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||
|
||||
// Detect if running as PWA
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if running in standalone mode (PWA)
|
||||
const checkPWA = () => {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone ||
|
||||
document.referrer.includes('android-app://');
|
||||
setIsPWA(isStandalone);
|
||||
|
||||
// Add class to html and body for CSS targeting
|
||||
if (isStandalone) {
|
||||
document.documentElement.classList.add('pwa-mode');
|
||||
document.body.classList.add('pwa-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('pwa-mode');
|
||||
document.body.classList.remove('pwa-mode');
|
||||
}
|
||||
};
|
||||
|
||||
checkPWA();
|
||||
|
||||
// Listen for changes
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA);
|
||||
|
||||
return () => {
|
||||
window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch projects on component mount
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
// Helper function to determine if an update is purely additive (new sessions/projects)
|
||||
// vs modifying existing selected items that would interfere with active conversations
|
||||
const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => {
|
||||
if (!selectedProject || !selectedSession) {
|
||||
// No active session to protect, allow all updates
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the selected project in both current and updated data
|
||||
const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name);
|
||||
const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name);
|
||||
|
||||
if (!currentSelectedProject || !updatedSelectedProject) {
|
||||
// Project structure changed significantly, not purely additive
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the selected session in both current and updated project data
|
||||
const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id);
|
||||
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
|
||||
|
||||
if (!currentSelectedSession || !updatedSelectedSession) {
|
||||
// Selected session was deleted or significantly changed, not purely additive
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the selected session's content has changed (modification vs addition)
|
||||
// Compare key fields that would affect the loaded chat interface
|
||||
const sessionUnchanged =
|
||||
currentSelectedSession.id === updatedSelectedSession.id &&
|
||||
currentSelectedSession.title === updatedSelectedSession.title &&
|
||||
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
|
||||
currentSelectedSession.updated_at === updatedSelectedSession.updated_at;
|
||||
|
||||
// This is considered additive if the selected session is unchanged
|
||||
// (new sessions may have been added elsewhere, but active session is protected)
|
||||
return sessionUnchanged;
|
||||
};
|
||||
|
||||
// Handle WebSocket messages for real-time project updates
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
if (latestMessage.type === 'projects_updated') {
|
||||
|
||||
// External Session Update Detection: Check if the changed file is the current session's JSONL
|
||||
// If so, and the session is not active, trigger a message reload in ChatInterface
|
||||
if (latestMessage.changedFile && selectedSession && selectedProject) {
|
||||
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
|
||||
const changedFileParts = latestMessage.changedFile.split('/');
|
||||
if (changedFileParts.length >= 2) {
|
||||
const filename = changedFileParts[changedFileParts.length - 1];
|
||||
const changedSessionId = filename.replace('.jsonl', '');
|
||||
|
||||
// Check if this is the currently-selected session
|
||||
if (changedSessionId === selectedSession.id) {
|
||||
const isSessionActive = activeSessions.has(selectedSession.id);
|
||||
|
||||
if (!isSessionActive) {
|
||||
// Session is not active - safe to reload messages
|
||||
console.log('🔄 External CLI update detected for current session:', changedSessionId);
|
||||
setExternalMessageUpdate(prev => prev + 1);
|
||||
} else {
|
||||
// Session is active - skip reload to avoid interrupting user
|
||||
console.log('⏸️ External update paused - session is active:', changedSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session Protection Logic: Allow additions but prevent changes during active conversations
|
||||
// This allows new sessions/projects to appear in sidebar while protecting active chat messages
|
||||
// We check for two types of active sessions:
|
||||
// 1. Existing sessions: selectedSession.id exists in activeSessions
|
||||
// 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received)
|
||||
const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) ||
|
||||
(activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-')));
|
||||
|
||||
if (hasActiveSession) {
|
||||
// Allow updates but be selective: permit additions, prevent changes to existing items
|
||||
const updatedProjects = latestMessage.projects;
|
||||
const currentProjects = projects;
|
||||
|
||||
// Check if this is purely additive (new sessions/projects) vs modification of existing ones
|
||||
const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession);
|
||||
|
||||
if (!isAdditiveUpdate) {
|
||||
// Skip updates that would modify existing selected session/project
|
||||
return;
|
||||
}
|
||||
// Continue with additive updates below
|
||||
}
|
||||
|
||||
// Update projects state with the new data from WebSocket
|
||||
const updatedProjects = latestMessage.projects;
|
||||
setProjects(updatedProjects);
|
||||
|
||||
// Update selected project if it exists in the updated projects
|
||||
if (selectedProject) {
|
||||
const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name);
|
||||
if (updatedSelectedProject) {
|
||||
// Only update selected project if it actually changed - prevents flickering
|
||||
if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) {
|
||||
setSelectedProject(updatedSelectedProject);
|
||||
}
|
||||
|
||||
// Update selected session only if it was deleted - avoid unnecessary reloads
|
||||
if (selectedSession) {
|
||||
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
|
||||
if (!updatedSelectedSession) {
|
||||
// Session was deleted
|
||||
setSelectedSession(null);
|
||||
}
|
||||
// Don't update if session still exists with same ID - prevents reload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [messages, selectedProject, selectedSession, activeSessions]);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
setIsLoadingProjects(true);
|
||||
const response = await api.projects();
|
||||
const data = await response.json();
|
||||
|
||||
// Always fetch Cursor sessions for each project so we can combine views
|
||||
for (let project of data) {
|
||||
try {
|
||||
const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`;
|
||||
const cursorResponse = await authenticatedFetch(url);
|
||||
if (cursorResponse.ok) {
|
||||
const cursorData = await cursorResponse.json();
|
||||
if (cursorData.success && cursorData.sessions) {
|
||||
project.cursorSessions = cursorData.sessions;
|
||||
} else {
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
} else {
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Cursor sessions for project ${project.name}:`, error);
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Optimize to preserve object references when data hasn't changed
|
||||
setProjects(prevProjects => {
|
||||
// If no previous projects, just set the new data
|
||||
if (prevProjects.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Check if the projects data has actually changed
|
||||
const hasChanges = data.some((newProject, index) => {
|
||||
const prevProject = prevProjects[index];
|
||||
if (!prevProject) return true;
|
||||
|
||||
// Compare key properties that would affect UI
|
||||
return (
|
||||
newProject.name !== prevProject.name ||
|
||||
newProject.displayName !== prevProject.displayName ||
|
||||
newProject.fullPath !== prevProject.fullPath ||
|
||||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
|
||||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) ||
|
||||
JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions)
|
||||
);
|
||||
}) || data.length !== prevProjects.length;
|
||||
|
||||
// Only update if there are actual changes
|
||||
return hasChanges ? data : prevProjects;
|
||||
});
|
||||
|
||||
// Don't auto-select any project - user should choose manually
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
} finally {
|
||||
setIsLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose fetchProjects globally for component access
|
||||
window.refreshProjects = fetchProjects;
|
||||
|
||||
// Handle URL-based session loading
|
||||
useEffect(() => {
|
||||
if (sessionId && projects.length > 0) {
|
||||
// Only switch tabs on initial load, not on every project update
|
||||
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
||||
// Find the session across all projects
|
||||
for (const project of projects) {
|
||||
let session = project.sessions?.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
setSelectedProject(project);
|
||||
setSelectedSession({ ...session, __provider: 'claude' });
|
||||
// Only switch to chat tab if we're loading a different session
|
||||
if (shouldSwitchTab) {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Also check Cursor sessions
|
||||
const cSession = project.cursorSessions?.find(s => s.id === sessionId);
|
||||
if (cSession) {
|
||||
setSelectedProject(project);
|
||||
setSelectedSession({ ...cSession, __provider: 'cursor' });
|
||||
if (shouldSwitchTab) {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If session not found, it might be a newly created session
|
||||
// Just navigate to it and it will be found when the sidebar refreshes
|
||||
// Don't redirect to home, let the session load naturally
|
||||
}
|
||||
}, [sessionId, projects, navigate]);
|
||||
|
||||
const handleProjectSelect = (project) => {
|
||||
setSelectedProject(project);
|
||||
setSelectedSession(null);
|
||||
navigate('/');
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionSelect = (session) => {
|
||||
setSelectedSession(session);
|
||||
// Only switch to chat tab when user explicitly selects a session
|
||||
// This prevents tab switching during automatic updates
|
||||
if (activeTab !== 'git' && activeTab !== 'preview') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
|
||||
// For Cursor sessions, we need to set the session ID differently
|
||||
// since they're persistent and not created by Claude
|
||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||
if (provider === 'cursor') {
|
||||
// Cursor sessions have persistent IDs
|
||||
sessionStorage.setItem('cursorSessionId', session.id);
|
||||
}
|
||||
|
||||
// Only close sidebar on mobile if switching to a different project
|
||||
if (isMobile) {
|
||||
const sessionProjectName = session.__projectName;
|
||||
const currentProjectName = selectedProject?.name;
|
||||
|
||||
// Close sidebar if clicking a session from a different project
|
||||
// Keep it open if clicking a session from the same project
|
||||
if (sessionProjectName !== currentProjectName) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}
|
||||
navigate(`/session/${session.id}`);
|
||||
};
|
||||
|
||||
const handleNewSession = (project) => {
|
||||
setSelectedProject(project);
|
||||
setSelectedSession(null);
|
||||
setActiveTab('chat');
|
||||
navigate('/');
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionDelete = (sessionId) => {
|
||||
// If the deleted session was currently selected, clear it
|
||||
if (selectedSession?.id === sessionId) {
|
||||
setSelectedSession(null);
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
// Update projects state locally instead of full refresh
|
||||
setProjects(prevProjects =>
|
||||
prevProjects.map(project => ({
|
||||
...project,
|
||||
sessions: project.sessions?.filter(session => session.id !== sessionId) || [],
|
||||
sessionMeta: {
|
||||
...project.sessionMeta,
|
||||
total: Math.max(0, (project.sessionMeta?.total || 0) - 1)
|
||||
}
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleSidebarRefresh = async () => {
|
||||
// Refresh only the sessions for all projects, don't change selected state
|
||||
try {
|
||||
const response = await api.projects();
|
||||
const freshProjects = await response.json();
|
||||
|
||||
// Optimize to preserve object references and minimize re-renders
|
||||
setProjects(prevProjects => {
|
||||
// Check if projects data has actually changed
|
||||
const hasChanges = freshProjects.some((newProject, index) => {
|
||||
const prevProject = prevProjects[index];
|
||||
if (!prevProject) return true;
|
||||
|
||||
return (
|
||||
newProject.name !== prevProject.name ||
|
||||
newProject.displayName !== prevProject.displayName ||
|
||||
newProject.fullPath !== prevProject.fullPath ||
|
||||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
|
||||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
|
||||
);
|
||||
}) || freshProjects.length !== prevProjects.length;
|
||||
|
||||
return hasChanges ? freshProjects : prevProjects;
|
||||
});
|
||||
|
||||
// If we have a selected project, make sure it's still selected after refresh
|
||||
if (selectedProject) {
|
||||
const refreshedProject = freshProjects.find(p => p.name === selectedProject.name);
|
||||
if (refreshedProject) {
|
||||
// Only update selected project if it actually changed
|
||||
if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) {
|
||||
setSelectedProject(refreshedProject);
|
||||
}
|
||||
|
||||
// If we have a selected session, try to find it in the refreshed project
|
||||
if (selectedSession) {
|
||||
const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id);
|
||||
if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) {
|
||||
setSelectedSession(refreshedSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing sidebar:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectDelete = (projectName) => {
|
||||
// If the deleted project was currently selected, clear it
|
||||
if (selectedProject?.name === projectName) {
|
||||
setSelectedProject(null);
|
||||
setSelectedSession(null);
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
// Update projects state locally instead of full refresh
|
||||
setProjects(prevProjects =>
|
||||
prevProjects.filter(project => project.name !== projectName)
|
||||
);
|
||||
};
|
||||
|
||||
// Session Protection Functions: Manage the lifecycle of active sessions
|
||||
|
||||
// markSessionAsActive: Called when user sends a message to mark session as protected
|
||||
// This includes both real session IDs and temporary "new-session-*" identifiers
|
||||
const markSessionAsActive = (sessionId) => {
|
||||
if (sessionId) {
|
||||
setActiveSessions(prev => new Set([...prev, sessionId]));
|
||||
}
|
||||
};
|
||||
|
||||
// markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates
|
||||
const markSessionAsInactive = (sessionId) => {
|
||||
if (sessionId) {
|
||||
setActiveSessions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(sessionId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Processing Session Functions: Track which sessions are currently thinking/processing
|
||||
|
||||
// markSessionAsProcessing: Called when Claude starts thinking/processing
|
||||
const markSessionAsProcessing = (sessionId) => {
|
||||
if (sessionId) {
|
||||
setProcessingSessions(prev => new Set([...prev, sessionId]));
|
||||
}
|
||||
};
|
||||
|
||||
// markSessionAsNotProcessing: Called when Claude finishes thinking/processing
|
||||
const markSessionAsNotProcessing = (sessionId) => {
|
||||
if (sessionId) {
|
||||
setProcessingSessions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(sessionId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// replaceTemporarySession: Called when WebSocket provides real session ID for new sessions
|
||||
// Removes temporary "new-session-*" identifiers and adds the real session ID
|
||||
// This maintains protection continuity during the transition from temporary to real session
|
||||
const replaceTemporarySession = (realSessionId) => {
|
||||
if (realSessionId) {
|
||||
setActiveSessions(prev => {
|
||||
const newSet = new Set();
|
||||
// Keep all non-temporary sessions and add the real session ID
|
||||
for (const sessionId of prev) {
|
||||
if (!sessionId.startsWith('new-session-')) {
|
||||
newSet.add(sessionId);
|
||||
}
|
||||
}
|
||||
newSet.add(realSessionId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Version Upgrade Modal Component
|
||||
const VersionUpgradeModal = () => {
|
||||
if (!showVersionModal) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
|
||||
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
|
||||
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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="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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isMobile && (
|
||||
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
|
||||
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
|
||||
}`}>
|
||||
<button
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{ height: 'calc(100vh - 80px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - Flexible */}
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-16' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
isMobile={isMobile}
|
||||
isPWA={isPWA}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
isLoading={isLoadingProjects}
|
||||
onInputFocusChange={setIsInputFocused}
|
||||
onSessionActive={markSessionAsActive}
|
||||
onSessionInactive={markSessionAsInactive}
|
||||
onSessionProcessing={markSessionAsProcessing}
|
||||
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||
processingSessions={processingSessions}
|
||||
onReplaceTemporarySession={replaceTemporarySession}
|
||||
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
<MobileNav
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
isInputFocused={isInputFocused}
|
||||
/>
|
||||
)}
|
||||
{/* Quick Settings Panel - Only show on chat tab */}
|
||||
{activeTab === 'chat' && (
|
||||
<QuickSettingsPanel
|
||||
isOpen={showQuickSettings}
|
||||
onToggle={setShowQuickSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
onAutoExpandChange={setAutoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
onShowRawParametersChange={setShowRawParameters}
|
||||
showThinking={showThinking}
|
||||
onShowThinkingChange={setShowThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
onAutoScrollChange={setAutoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onSendByCtrlEnterChange={setSendByCtrlEnter}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings Modal */}
|
||||
<Settings
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* Version Upgrade Modal */}
|
||||
<VersionUpgradeModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Root App component with router
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
35
src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AppContent from './components/app/AppContent';
|
||||
import i18n from './i18n/config.js';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [fakeTokens, setFakeTokens] = useState(0);
|
||||
|
||||
// Update elapsed time every second
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
setFakeTokens(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
// Simulate token count increasing over time (roughly 30-50 tokens per second)
|
||||
setFakeTokens(Math.floor(elapsed * (30 + Math.random() * 20)));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Animate the status indicator
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setAnimationPhase(prev => (prev + 1) % 4);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Don't show if loading is false
|
||||
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
|
||||
if (!isLoading) return null;
|
||||
|
||||
// Clever action words that cycle
|
||||
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
|
||||
// Parse status data
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
const tokens = status?.tokens || fakeTokens;
|
||||
const canInterrupt = status?.can_interrupt !== false;
|
||||
|
||||
// Animation characters
|
||||
const spinners = ['✻', '✹', '✸', '✶'];
|
||||
const currentSpinner = spinners[animationPhase];
|
||||
|
||||
return (
|
||||
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-900 dark:bg-gray-950 text-white rounded-lg shadow-lg px-4 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-xl transition-all duration-500",
|
||||
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
|
||||
)}>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - first line */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{statusText}...</span>
|
||||
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
|
||||
{tokens > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-300 text-sm hidden sm:inline">⚒ {tokens.toLocaleString()} tokens</span>
|
||||
<span className="text-gray-300 text-sm sm:hidden">⚒ {tokens.toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-400 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-sm hidden sm:inline">esc to interrupt</span>
|
||||
</div>
|
||||
{/* Second line for mobile */}
|
||||
<div className="text-xs text-gray-400 sm:hidden mt-1">
|
||||
esc to interrupt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaudeStatus;
|
||||
@@ -1,422 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, Decoration } from '@codemirror/view';
|
||||
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath }) {
|
||||
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 [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
|
||||
// Create diff highlighting
|
||||
const diffEffect = StateEffect.define();
|
||||
|
||||
const diffField = StateField.define({
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations, tr) {
|
||||
decorations = decorations.map(tr.changes);
|
||||
|
||||
for (let effect of tr.effects) {
|
||||
if (effect.is(diffEffect)) {
|
||||
decorations = effect.value;
|
||||
}
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f)
|
||||
});
|
||||
|
||||
const createDiffDecorations = (content, diffInfo) => {
|
||||
if (!diffInfo || !showDiff) return Decoration.none;
|
||||
|
||||
const builder = new RangeSetBuilder();
|
||||
const lines = content.split('\n');
|
||||
const oldLines = diffInfo.old_string.split('\n');
|
||||
|
||||
// Find the line where the old content starts
|
||||
let startLineIndex = -1;
|
||||
for (let i = 0; i <= lines.length - oldLines.length; i++) {
|
||||
let matches = true;
|
||||
for (let j = 0; j < oldLines.length; j++) {
|
||||
if (lines[i + j] !== oldLines[j]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
startLineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startLineIndex >= 0) {
|
||||
let pos = 0;
|
||||
// Calculate position to start of old content
|
||||
for (let i = 0; i < startLineIndex; i++) {
|
||||
pos += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
// Highlight old lines (to be removed)
|
||||
for (let i = 0; i < oldLines.length; i++) {
|
||||
const lineStart = pos;
|
||||
const lineEnd = pos + oldLines[i].length;
|
||||
builder.add(lineStart, lineEnd, Decoration.line({
|
||||
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
|
||||
}));
|
||||
pos += oldLines[i].length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
};
|
||||
|
||||
// Diff decoration theme
|
||||
const diffTheme = EditorView.theme({
|
||||
'.diff-removed-light': {
|
||||
backgroundColor: '#fef2f2',
|
||||
borderLeft: '3px solid #ef4444'
|
||||
},
|
||||
'.diff-removed-dark': {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderLeft: '3px solid #ef4444'
|
||||
},
|
||||
'.diff-added-light': {
|
||||
backgroundColor: '#f0fdf4',
|
||||
borderLeft: '3px solid #22c55e'
|
||||
},
|
||||
'.diff-added-dark': {
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderLeft: '3px solid #22c55e'
|
||||
}
|
||||
});
|
||||
|
||||
// Get language extension based on file extension
|
||||
const getLanguageExtension = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
|
||||
case 'py':
|
||||
return [python()];
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return [html()];
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return [css()];
|
||||
case 'json':
|
||||
return [json()];
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return [markdown()];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Load file content
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setContent(data.content);
|
||||
} catch (error) {
|
||||
console.error('Error loading file:', error);
|
||||
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [file, projectPath]);
|
||||
|
||||
// Update diff decorations when content or diff info changes
|
||||
const editorRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && content && file.diffInfo && showDiff) {
|
||||
const decorations = createDiffDecorations(content, file.diffInfo);
|
||||
const view = editorRef.current.view;
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: diffEffect.of(decorations)
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [content, file.diffInfo, showDiff, isDarkMode]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await api.saveFile(file.projectName, file.path, content);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success feedback
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
alert(`Error saving file: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [content]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.code-editor-loading {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
.code-editor-loading:hover {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
`}
|
||||
</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">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 ${
|
||||
// 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]')
|
||||
}`}>
|
||||
{/* 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 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>
|
||||
{file.diffInfo && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
📝 Has changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 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"
|
||||
title="Download file"
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Saved!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
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="Close"
|
||||
>
|
||||
<X className="w-6 h-6 md:w-4 md:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
diffField,
|
||||
diffTheme,
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
||||
<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">
|
||||
Press Ctrl+S to save • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -1,344 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* CommandMenu - Autocomplete dropdown for slash commands
|
||||
*
|
||||
* @param {Array} commands - Array of command objects to display
|
||||
* @param {number} selectedIndex - Currently selected command index
|
||||
* @param {Function} onSelect - Callback when a command is selected
|
||||
* @param {Function} onClose - Callback when menu should close
|
||||
* @param {Object} position - Position object { top, left } for absolute positioning
|
||||
* @param {boolean} isOpen - Whether the menu is open
|
||||
* @param {Array} frequentCommands - Array of frequently used command objects
|
||||
*/
|
||||
const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => {
|
||||
const menuRef = useRef(null);
|
||||
const selectedItemRef = useRef(null);
|
||||
|
||||
// Calculate responsive positioning
|
||||
const getMenuPosition = () => {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuHeight = 300; // Max height of menu
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, calculate bottom position dynamically to appear above the input
|
||||
// Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing
|
||||
const inputBottom = position.bottom || 90; // Use provided bottom or default
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${inputBottom}px`, // Position above the input with spacing already included
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px
|
||||
};
|
||||
}
|
||||
|
||||
// On desktop, use provided position but ensure it stays on screen
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px'
|
||||
};
|
||||
};
|
||||
|
||||
const menuPosition = getMenuPosition();
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedItemRef.current && menuRef.current) {
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const itemRect = selectedItemRef.current.getBoundingClientRect();
|
||||
|
||||
if (itemRect.bottom > menuRect.bottom) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else if (itemRect.top < menuRect.top) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show a message if no commands are available
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add frequent commands as a special group if provided
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
|
||||
// Group commands by namespace
|
||||
const groupedCommands = commands.reduce((groups, command) => {
|
||||
const namespace = command.namespace || command.type || 'other';
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push(command);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
// Add frequent commands as a separate group
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands['frequent'] = frequentCommands;
|
||||
}
|
||||
|
||||
// Order: frequent, builtin, project, user, other
|
||||
const namespaceOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]);
|
||||
|
||||
const namespaceLabels = {
|
||||
frequent: '⭐ Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands'
|
||||
};
|
||||
|
||||
// Calculate global index for each command
|
||||
let globalIndex = 0;
|
||||
const commandsWithIndex = [];
|
||||
orderedNamespaces.forEach(namespace => {
|
||||
groupedCommands[namespace].forEach(command => {
|
||||
commandsWithIndex.push({
|
||||
...command,
|
||||
globalIndex: globalIndex++,
|
||||
namespace
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out'
|
||||
}}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
color: '#6b7280',
|
||||
padding: '8px 12px 4px',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
{groupedCommands[namespace].map((command) => {
|
||||
const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace);
|
||||
const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className="command-item"
|
||||
onMouseEnter={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, true)}
|
||||
onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
||||
transition: 'background-color 100ms ease-in-out',
|
||||
marginBottom: '2px'
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: command.description ? '4px' : 0
|
||||
}}
|
||||
>
|
||||
{/* Command icon based on namespace */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{namespace === 'builtin' && '⚡'}
|
||||
{namespace === 'project' && '📁'}
|
||||
{namespace === 'user' && '👤'}
|
||||
{namespace === 'other' && '📝'}
|
||||
</span>
|
||||
|
||||
{/* Command name */}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#111827',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
|
||||
{/* Command metadata badge */}
|
||||
{command.metadata?.type && (
|
||||
<span
|
||||
className="command-metadata-badge"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#6b7280',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command description */}
|
||||
{command.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
marginLeft: '24px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
color: '#3b82f6',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
↵
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Default light mode styles */}
|
||||
<style>{`
|
||||
.command-menu {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.command-menu {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #374151 !important;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
.command-item[aria-selected="true"] {
|
||||
background-color: #1e40af !important;
|
||||
}
|
||||
.command-item span:not(.command-metadata-badge) {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
.command-metadata-badge {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.command-item div {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
.command-group > div:first-child {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandMenu;
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }) => {
|
||||
return (
|
||||
<img src="/icons/cursor.svg" alt="Cursor" className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorLogo;
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
function DarkModeToggle() {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
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={isDarkMode}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<span className="sr-only">Toggle dark mode</span>
|
||||
<span
|
||||
className={`${
|
||||
isDarkMode ? '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`}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<svg className="w-3.5 h-3.5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DarkModeToggle;
|
||||
48
src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
type DarkModeToggleProps = {
|
||||
checked?: boolean;
|
||||
onToggle?: (nextValue: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
function DarkModeToggle({ checked, onToggle, ariaLabel = 'Toggle dark mode' }: DarkModeToggleProps) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
|
||||
const isEnabled = isControlled ? checked : isDarkMode;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isControlled) {
|
||||
onToggle(!isEnabled);
|
||||
return;
|
||||
}
|
||||
|
||||
toggleDarkMode();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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={isEnabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className="sr-only">{ariaLabel}</span>
|
||||
<span
|
||||
className={`${
|
||||
isEnabled ? 'translate-x-7' : 'translate-x-1'
|
||||
} h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<Moon className="h-3.5 w-3.5 text-gray-700" />
|
||||
) : (
|
||||
<Sun className="h-3.5 w-3.5 text-yellow-500" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DarkModeToggle;
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||
if (!diff) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
@@ -17,13 +17,13 @@ function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs p-2 ${
|
||||
className={`font-mono text-xs px-3 py-0.5 ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950/50 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-primary/5 text-primary' :
|
||||
'text-muted-foreground/70'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
|
||||
@@ -1,73 +1,77 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log the error details
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// You can also log the error to an error reporting service here
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Fallback UI
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="ml-3 text-sm font-medium text-red-800">
|
||||
Something went wrong
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="mb-2">An error occurred while loading the chat interface.</p>
|
||||
{this.props.showDetails && this.state.error && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
|
||||
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
if (this.props.onRetry) this.props.onRetry();
|
||||
}}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="ml-3 text-sm font-medium text-red-800">
|
||||
Something went wrong
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="mb-2">An error occurred while loading the chat interface.</p>
|
||||
{showDetails && error && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
|
||||
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
|
||||
{error.toString()}
|
||||
{componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
|
||||
const [componentStack, setComponentStack] = useState(null);
|
||||
|
||||
const handleError = useCallback((error, errorInfo) => {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
setComponentStack(errorInfo?.componentStack || null);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setComponentStack(null);
|
||||
onRetry?.();
|
||||
}, [onRetry]);
|
||||
|
||||
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
|
||||
<ErrorFallback
|
||||
error={error}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
showDetails={showDetails}
|
||||
componentStack={componentStack}
|
||||
/>
|
||||
), [showDetails, componentStack]);
|
||||
|
||||
return (
|
||||
<ReactErrorBoundary
|
||||
fallbackRender={renderFallback}
|
||||
onError={handleError}
|
||||
onReset={handleReset}
|
||||
resetKeys={resetKeys}
|
||||
>
|
||||
{children}
|
||||
</ReactErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function FileTree({ selectedProject }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
// Load view mode preference from localStorage
|
||||
useEffect(() => {
|
||||
const savedViewMode = localStorage.getItem('file-tree-view-mode');
|
||||
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
|
||||
setViewMode(savedViewMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Filter files based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFiles(files);
|
||||
} else {
|
||||
const filtered = filterFiles(files, searchQuery.toLowerCase());
|
||||
setFilteredFiles(filtered);
|
||||
|
||||
// Auto-expand directories that contain matches
|
||||
const expandMatches = (items) => {
|
||||
items.forEach(item => {
|
||||
if (item.type === 'directory' && item.children && item.children.length > 0) {
|
||||
setExpandedDirs(prev => new Set(prev.add(item.path)));
|
||||
expandMatches(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
expandMatches(filtered);
|
||||
}
|
||||
}, [files, searchQuery]);
|
||||
|
||||
// Recursively filter files and directories based on search query
|
||||
const filterFiles = (items, query) => {
|
||||
return items.reduce((filtered, item) => {
|
||||
const matchesName = item.name.toLowerCase().includes(query);
|
||||
let filteredChildren = [];
|
||||
|
||||
if (item.type === 'directory' && item.children) {
|
||||
filteredChildren = filterFiles(item.children, query);
|
||||
}
|
||||
|
||||
// Include item if:
|
||||
// 1. It matches the search query, or
|
||||
// 2. It's a directory with matching children
|
||||
if (matchesName || filteredChildren.length > 0) {
|
||||
filtered.push({
|
||||
...item,
|
||||
children: filteredChildren
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ File fetch failed:', response.status, errorText);
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setFiles(data);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching files:', error);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDirectory = (path) => {
|
||||
const newExpanded = new Set(expandedDirs);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedDirs(newExpanded);
|
||||
};
|
||||
|
||||
// Change view mode and save preference
|
||||
const changeViewMode = (mode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('file-tree-view-mode', mode);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Format date as relative time
|
||||
const formatRelativeTime = (date) => {
|
||||
if (!date) return '-';
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInSeconds = Math.floor((now - past) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
|
||||
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
|
||||
return past.toLocaleDateString();
|
||||
};
|
||||
|
||||
const renderFileTree = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
// Open image in viewer
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
// Open file in editor
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
item.children.length > 0 && (
|
||||
<div>
|
||||
{renderFileTree(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const isImageFile = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
const getFileIcon = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
|
||||
const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'php', 'rb', 'go', 'rs'];
|
||||
const docExtensions = ['md', 'txt', 'doc', 'pdf'];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
|
||||
|
||||
if (codeExtensions.includes(ext)) {
|
||||
return <FileCode className="w-4 h-4 text-green-500 flex-shrink-0" />;
|
||||
} else if (docExtensions.includes(ext)) {
|
||||
return <FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />;
|
||||
} else if (imageExtensions.includes(ext)) {
|
||||
return <File className="w-4 h-4 text-purple-500 flex-shrink-0" />;
|
||||
} else {
|
||||
return <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Render detailed view with table-like layout
|
||||
const renderDetailedView = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-12 gap-2 p-2 hover:bg-accent cursor-pointer items-center",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-2 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : '-'}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(item.modified)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">
|
||||
{item.permissionsRwx || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
renderDetailedView(item.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// Render compact view with inline details
|
||||
const renderCompactView = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 hover:bg-accent cursor-pointer",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
renderCompactView(item.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
Loading files...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
{/* Header with Search and View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search files and folders..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers for Detailed View */}
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-5">Name</div>
|
||||
<div className="col-span-2">Size</div>
|
||||
<div className="col-span-3">Modified</div>
|
||||
<div className="col-span-2">Permissions</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No files found</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check if the project path is accessible
|
||||
</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && searchQuery ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try a different search term or clear the search
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
{selectedFile && (
|
||||
<CodeEditor
|
||||
file={selectedFile}
|
||||
onClose={() => setSelectedFile(null)}
|
||||
projectPath={selectedFile.projectPath}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image Viewer Modal */}
|
||||
{selectedImage && (
|
||||
<ImageViewer
|
||||
file={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTree;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React 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)}`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{file.name}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{file.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageViewer;
|
||||
74
src/components/LanguageSelector.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Language Selector Component
|
||||
*
|
||||
* A dropdown component for selecting the application language.
|
||||
* Automatically updates the i18n language and persists to localStorage.
|
||||
*
|
||||
* Props:
|
||||
* @param {boolean} compact - If true, uses compact style (default: false)
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { languages } from '../i18n/languages';
|
||||
|
||||
function LanguageSelector({ compact = false }) {
|
||||
const { i18n, t } = useTranslation('settings');
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const newLanguage = event.target.value;
|
||||
i18n.changeLanguage(newLanguage);
|
||||
};
|
||||
|
||||
// Compact style for QuickSettingsPanel
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('account.language')}
|
||||
</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
className="w-[100px] 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 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full style for Settings page
|
||||
return (
|
||||
<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-gray-900 dark:text-gray-100 mb-1">
|
||||
{t('account.languageLabel')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('account.languageDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
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-36"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,32 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { t } = useTranslation('auth');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
setError(t('errors.requiredFields'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -41,9 +43,9 @@ const LoginForm = () => {
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('login.title')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Sign in to your Claude Code UI account
|
||||
{t('login.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +53,7 @@ const LoginForm = () => {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
|
||||
Username
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -59,7 +61,7 @@ const LoginForm = () => {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
placeholder={t('login.placeholders.username')}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -67,7 +69,7 @@ const LoginForm = () => {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -75,7 +77,7 @@ const LoginForm = () => {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('login.placeholders.password')}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -92,7 +94,7 @@ const LoginForm = () => {
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
{isLoading ? t('login.loading') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
92
src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './standalone-shell/view/StandaloneShell';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
/**
|
||||
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - Whether the modal is visible
|
||||
* @param {Function} props.onClose - Callback when modal is closed
|
||||
* @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with
|
||||
* @param {Object} props.project - Project object containing name and path information
|
||||
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||
* @param {string} props.customCommand - Optional custom command to override defaults
|
||||
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
|
||||
*/
|
||||
function LoginModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider = 'claude',
|
||||
project,
|
||||
onComplete,
|
||||
customCommand,
|
||||
isAuthenticated = false,
|
||||
isOnboarding = false
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCommand = () => {
|
||||
if (customCommand) return customCommand;
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return 'Claude CLI Login';
|
||||
case 'cursor':
|
||||
return 'Cursor CLI Login';
|
||||
case 'codex':
|
||||
return 'Codex CLI Login';
|
||||
default:
|
||||
return 'CLI Login';
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = (exitCode) => {
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
// Keep modal open so users can read login output and close explicitly.
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{getTitle()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close login modal"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginModal;
|
||||
@@ -1,587 +0,0 @@
|
||||
/*
|
||||
* MainContent.jsx - Main Content Area with Session Protection Props Passthrough
|
||||
*
|
||||
* SESSION PROTECTION PASSTHROUGH:
|
||||
* ===============================
|
||||
*
|
||||
* This component serves as a passthrough layer for Session Protection functions:
|
||||
* - Receives session management functions from App.jsx
|
||||
* - Passes them down to ChatInterface.jsx
|
||||
*
|
||||
* No session protection logic is implemented here - it's purely a props bridge.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import GitPanel from './GitPanel';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import TaskList from './TaskList';
|
||||
import TaskDetail from './TaskDetail';
|
||||
import PRDEditor from './PRDEditor';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
messages,
|
||||
isMobile,
|
||||
isPWA,
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
// Session Protection Props: Functions passed down from App.jsx to manage active session state
|
||||
// These functions control when project updates are paused during active conversations
|
||||
onSessionActive, // Mark session as active when user sends message
|
||||
onSessionInactive, // Mark session as inactive when conversation completes/aborts
|
||||
onSessionProcessing, // Mark session as processing (thinking/working)
|
||||
onSessionNotProcessing, // Mark session as not processing (finished thinking)
|
||||
processingSessions, // Set of session IDs currently processing
|
||||
onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
|
||||
onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround)
|
||||
onShowSettings, // Show tools settings panel
|
||||
autoExpandTools, // Auto-expand tool accordions
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
showThinking, // Show thinking/reasoning sections
|
||||
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
|
||||
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
|
||||
externalMessageUpdate // Trigger for external CLI updates to current session
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
|
||||
// PRD Editor state
|
||||
const [showPRDEditor, setShowPRDEditor] = useState(false);
|
||||
const [selectedPRD, setSelectedPRD] = useState(null);
|
||||
const [existingPRDs, setExistingPRDs] = useState([]);
|
||||
const [prdNotification, setPRDNotification] = useState(null);
|
||||
|
||||
// TaskMaster context
|
||||
const { tasks, currentProject, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
const { tasksEnabled, isTaskMasterInstalled, isTaskMasterReady } = useTasksSettings();
|
||||
|
||||
// Only show tasks tab if TaskMaster is installed and enabled
|
||||
const shouldShowTasksTab = tasksEnabled && isTaskMasterInstalled;
|
||||
|
||||
// Sync selectedProject with TaskMaster context
|
||||
useEffect(() => {
|
||||
if (selectedProject && selectedProject !== currentProject) {
|
||||
setCurrentProject(selectedProject);
|
||||
}
|
||||
}, [selectedProject, currentProject, setCurrentProject]);
|
||||
|
||||
// Switch away from tasks tab when tasks are disabled or TaskMaster is not installed
|
||||
useEffect(() => {
|
||||
if (!shouldShowTasksTab && activeTab === 'tasks') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
||||
|
||||
// Load existing PRDs when current project changes
|
||||
useEffect(() => {
|
||||
const loadExistingPRDs = async () => {
|
||||
if (!currentProject?.name) {
|
||||
setExistingPRDs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
} else {
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing PRDs:', error);
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadExistingPRDs();
|
||||
}, [currentProject?.name]);
|
||||
|
||||
const handleFileOpen = (filePath, diffInfo = null) => {
|
||||
// Create a file object that CodeEditor expects
|
||||
const file = {
|
||||
name: filePath.split('/').pop(),
|
||||
path: filePath,
|
||||
projectName: selectedProject?.name,
|
||||
diffInfo: diffInfo // Pass along diff information if available
|
||||
};
|
||||
setEditingFile(file);
|
||||
};
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setEditingFile(null);
|
||||
};
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
// If task is just an ID (from dependency click), find the full task object
|
||||
if (typeof task === 'object' && task.id && !task.title) {
|
||||
const fullTask = tasks?.find(t => t.id === task.id);
|
||||
if (fullTask) {
|
||||
setSelectedTask(fullTask);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
} else {
|
||||
setSelectedTask(task);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDetailClose = () => {
|
||||
setShowTaskDetail(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const handleTaskStatusChange = (taskId, newStatus) => {
|
||||
// This would integrate with TaskMaster API to update task status
|
||||
console.log('Update task status:', taskId, newStatus);
|
||||
refreshTasks?.();
|
||||
};
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 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-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-12 h-12 mx-auto mb-4">
|
||||
<div
|
||||
className="w-full h-full rounded-full border-4 border-gray-200 border-t-blue-500"
|
||||
style={{
|
||||
animation: 'spin 1s linear infinite',
|
||||
WebkitAnimation: 'spin 1s linear infinite',
|
||||
MozAnimation: 'spin 1s linear infinite'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
|
||||
<p>Setting up your workspace...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 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-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6">
|
||||
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">Choose Your Project</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.
|
||||
</p>
|
||||
<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-700 dark:text-blue-300">
|
||||
💡 <strong>Tip:</strong> {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with tabs */}
|
||||
<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 space-x-2 sm:space-x-3">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
onMenuClick();
|
||||
}}
|
||||
className="p-2.5 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-700 touch-manipulation active:scale-95 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
{activeTab === 'chat' && selectedSession && (
|
||||
<div className="w-6 h-6 flex-shrink-0 flex items-center justify-center">
|
||||
{selectedSession.__provider === 'cursor' ? (
|
||||
<CursorLogo className="w-5 h-5" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName} <span className="hidden sm:inline">• {selectedSession.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'chat' && !selectedSession ? (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
New Session
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{activeTab === 'files' ? 'Project Files' :
|
||||
activeTab === 'git' ? 'Source Control' :
|
||||
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
|
||||
'Project'}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modern Tab Navigation - Right Side */}
|
||||
<div className="flex-shrink-0 hidden sm:block">
|
||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<Tooltip content="Chat" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Chat</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Shell" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('shell')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'shell'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Shell</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Files" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'files'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Files</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Source Control" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('git')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Source Control</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{shouldShowTasksTab && (
|
||||
<Tooltip content="Tasks" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'tasks'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Tasks</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* <button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Preview</span>
|
||||
</span>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onSessionProcessing={onSessionProcessing}
|
||||
onSessionNotProcessing={onSessionNotProcessing}
|
||||
processingSessions={processingSessions}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
isActive={activeTab === 'shell'}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} />
|
||||
</div>
|
||||
{shouldShowTasksTab && (
|
||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<TaskList
|
||||
tasks={tasks || []}
|
||||
onTaskClick={handleTaskClick}
|
||||
showParentTasks={true}
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
currentProject={currentProject}
|
||||
onTaskCreated={refreshTasks}
|
||||
onShowPRDEditor={(prd = null) => {
|
||||
setSelectedPRD(prd);
|
||||
setShowPRDEditor(true);
|
||||
}}
|
||||
existingPRDs={existingPRDs}
|
||||
onRefreshPRDs={(showNotification = false) => {
|
||||
// Reload existing PRDs
|
||||
if (currentProject?.name) {
|
||||
api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`)
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
if (showNotification) {
|
||||
setPRDNotification('PRD saved successfully!');
|
||||
setTimeout(() => setPRDNotification(null), 3000);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to refresh PRDs:', error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`}>
|
||||
{/* <LivePreviewPanel
|
||||
selectedProject={selectedProject}
|
||||
serverStatus={serverStatus}
|
||||
serverUrl={serverUrl}
|
||||
availableScripts={availableScripts}
|
||||
onStartServer={(script) => {
|
||||
sendMessage({
|
||||
type: 'server:start',
|
||||
projectPath: selectedProject?.fullPath,
|
||||
script: script
|
||||
});
|
||||
}}
|
||||
onStopServer={() => {
|
||||
sendMessage({
|
||||
type: 'server:stop',
|
||||
projectPath: selectedProject?.fullPath
|
||||
});
|
||||
}}
|
||||
onScriptSelect={setCurrentScript}
|
||||
currentScript={currentScript}
|
||||
isMobile={isMobile}
|
||||
serverLogs={serverLogs}
|
||||
onClearLogs={() => setServerLogs([])}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
{editingFile && (
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{shouldShowTasksTab && showTaskDetail && selectedTask && (
|
||||
<TaskDetail
|
||||
task={selectedTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={handleTaskDetailClose}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
)}
|
||||
{/* PRD Editor Modal */}
|
||||
{showPRDEditor && (
|
||||
<PRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={() => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
}}
|
||||
isNewFile={!selectedPRD?.isExisting}
|
||||
file={{
|
||||
name: selectedPRD?.name || 'prd.txt',
|
||||
content: selectedPRD?.content || ''
|
||||
}}
|
||||
onSave={async () => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
|
||||
// Reload existing PRDs with notification
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
setPRDNotification('PRD saved successfully!');
|
||||
setTimeout(() => setPRDNotification(null), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh PRDs:', error);
|
||||
}
|
||||
|
||||
refreshTasks?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* PRD Notification */}
|
||||
{prdNotification && (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium">{prdNotification}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MainContent);
|
||||
@@ -1,272 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Mic, Loader2, Brain } from 'lucide-react';
|
||||
import { transcribeWithWhisper } from '../utils/whisper';
|
||||
|
||||
export function MicButton({ onTranscript, className = '' }) {
|
||||
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
|
||||
const [error, setError] = useState(null);
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const lastTapRef = useRef(0);
|
||||
|
||||
// Check microphone support on mount
|
||||
useEffect(() => {
|
||||
const checkSupport = () => {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
setIsSupported(false);
|
||||
setError('Microphone not supported. Please use HTTPS or a modern browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional check for secure context
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
setIsSupported(false);
|
||||
setError('Microphone requires HTTPS. Please use a secure connection.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
checkSupport();
|
||||
}, []);
|
||||
|
||||
// Start recording
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
console.log('Starting recording...');
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
|
||||
// Check if getUserMedia is available
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
console.log('Recording stopped, creating blob...');
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
|
||||
// Clean up stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Start transcribing
|
||||
setState('transcribing');
|
||||
|
||||
// Check if we're in an enhancement mode
|
||||
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
|
||||
const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
|
||||
|
||||
// Set up a timer to switch to processing state for enhancement modes
|
||||
let processingTimer;
|
||||
if (isEnhancementMode) {
|
||||
processingTimer = setTimeout(() => {
|
||||
setState('processing');
|
||||
}, 2000); // Switch to processing after 2 seconds
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await transcribeWithWhisper(blob);
|
||||
if (text && onTranscript) {
|
||||
onTranscript(text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transcription error:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
if (processingTimer) {
|
||||
clearTimeout(processingTimer);
|
||||
}
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setState('recording');
|
||||
console.log('Recording started successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
|
||||
// Provide specific error messages based on error type
|
||||
let errorMessage = 'Microphone access failed';
|
||||
|
||||
if (err.name === 'NotAllowedError') {
|
||||
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
errorMessage = 'No microphone found. Please check your audio devices.';
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
errorMessage = 'Microphone not supported by this browser.';
|
||||
} else if (err.name === 'NotReadableError') {
|
||||
errorMessage = 'Microphone is being used by another application.';
|
||||
} else if (err.message.includes('HTTPS')) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = () => {
|
||||
console.log('Stopping recording...');
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
// Don't set state here - let the onstop handler do it
|
||||
} else {
|
||||
// If recorder isn't in recording state, force cleanup
|
||||
console.log('Recorder not in recording state, forcing cleanup');
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle button click
|
||||
const handleClick = (e) => {
|
||||
// Prevent double firing on mobile
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Don't proceed if microphone is not supported
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce for mobile double-tap issue
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < 300) {
|
||||
console.log('Ignoring rapid tap');
|
||||
return;
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
|
||||
console.log('Button clicked, current state:', state);
|
||||
|
||||
if (state === 'idle') {
|
||||
startRecording();
|
||||
} else if (state === 'recording') {
|
||||
stopRecording();
|
||||
}
|
||||
// Do nothing if transcribing or processing
|
||||
};
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Button appearance based on state
|
||||
const getButtonAppearance = () => {
|
||||
if (!isSupported) {
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5" />,
|
||||
className: 'bg-gray-400 cursor-not-allowed',
|
||||
disabled: true
|
||||
};
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5 text-white" />,
|
||||
className: 'bg-red-500 hover:bg-red-600 animate-pulse',
|
||||
disabled: false
|
||||
};
|
||||
case 'transcribing':
|
||||
return {
|
||||
icon: <Loader2 className="w-5 h-5 animate-spin" />,
|
||||
className: 'bg-blue-500 hover:bg-blue-600',
|
||||
disabled: true
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
icon: <Brain className="w-5 h-5 animate-pulse" />,
|
||||
className: 'bg-purple-500 hover:bg-purple-600',
|
||||
disabled: true
|
||||
};
|
||||
default: // idle
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5" />,
|
||||
className: 'bg-gray-700 hover:bg-gray-600',
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, className: buttonClass, disabled } = getButtonAppearance();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
backgroundColor: state === 'recording' ? '#ef4444' :
|
||||
state === 'transcribing' ? '#3b82f6' :
|
||||
state === 'processing' ? '#a855f7' :
|
||||
'#374151'
|
||||
}}
|
||||
className={`
|
||||
flex items-center justify-center
|
||||
w-12 h-12 rounded-full
|
||||
text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||
dark:ring-offset-gray-800
|
||||
touch-action-manipulation
|
||||
${disabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
|
||||
${state === 'recording' ? 'animate-pulse' : ''}
|
||||
hover:opacity-90
|
||||
${className}
|
||||
`}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
|
||||
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
|
||||
animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'recording' && (
|
||||
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +1,90 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe, CheckSquare } from 'lucide-react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
|
||||
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
// Detect dark mode
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'chat',
|
||||
icon: MessageSquare,
|
||||
label: 'Chat',
|
||||
onClick: () => setActiveTab('chat')
|
||||
},
|
||||
{
|
||||
id: 'shell',
|
||||
icon: Terminal,
|
||||
label: 'Shell',
|
||||
onClick: () => setActiveTab('shell')
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
icon: Folder,
|
||||
label: 'Files',
|
||||
onClick: () => setActiveTab('files')
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
icon: GitBranch,
|
||||
label: 'Git',
|
||||
onClick: () => setActiveTab('git')
|
||||
},
|
||||
// Conditionally add tasks tab if enabled
|
||||
...(tasksEnabled ? [{
|
||||
...(shouldShowTasksTab ? [{
|
||||
id: 'tasks',
|
||||
icon: CheckSquare,
|
||||
icon: ClipboardCheck,
|
||||
label: 'Tasks',
|
||||
onClick: () => setActiveTab('tasks')
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.mobile-nav-container {
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
|
||||
}
|
||||
.mobile-nav-container:hover {
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
className={`mobile-nav-container fixed bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
|
||||
isInputFocused ? 'translate-y-full' : 'translate-y-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-around py-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`flex items-center justify-center p-2 rounded-lg min-h-[40px] min-w-[40px] relative touch-manipulation ${
|
||||
isActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
aria-label={item.id}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 z-50 px-3 pb-[max(8px,env(safe-area-inset-bottom))] transform transition-transform duration-300 ease-in-out ${
|
||||
isInputFocused ? 'translate-y-full' : 'translate-y-0'
|
||||
}`}
|
||||
>
|
||||
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
|
||||
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
|
||||
)}
|
||||
<Icon
|
||||
className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
|
||||
strokeWidth={isActive ? 2.4 : 1.8}
|
||||
/>
|
||||
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNav;
|
||||
export default MobileNav;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause,
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { api } from '../utils/api';
|
||||
import Shell from './Shell';
|
||||
import Shell from './shell/view/Shell';
|
||||
import TaskDetail from './TaskDetail';
|
||||
|
||||
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
|
||||
|
||||
585
src/components/Onboarding.jsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
|
||||
import LoginModal from './LoginModal';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const Onboarding = ({ onComplete }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [gitName, setGitName] = useState('');
|
||||
const [gitEmail, setGitEmail] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
|
||||
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
|
||||
|
||||
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [cursorAuthStatus, setCursorAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [codexAuthStatus, setCodexAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const prevActiveLoginProviderRef = useRef(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadGitConfig();
|
||||
}, []);
|
||||
|
||||
const loadGitConfig = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/user/git-config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.gitName) setGitName(data.gitName);
|
||||
if (data.gitEmail) setGitEmail(data.gitEmail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading git config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const prevProvider = prevActiveLoginProviderRef.current;
|
||||
prevActiveLoginProviderRef.current = activeLoginProvider;
|
||||
|
||||
const isInitialMount = prevProvider === undefined;
|
||||
const isModalClosing = prevProvider !== null && activeLoginProvider === null;
|
||||
|
||||
if (isInitialMount || isModalClosing) {
|
||||
checkClaudeAuthStatus();
|
||||
checkCursorAuthStatus();
|
||||
checkCodexAuthStatus();
|
||||
}
|
||||
}, [activeLoginProvider]);
|
||||
|
||||
const checkClaudeAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/claude/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setClaudeAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
setClaudeAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkCursorAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/cursor/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCursorAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setCursorAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
setCursorAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkCodexAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/codex/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCodexAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setCodexAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
setCodexAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaudeLogin = () => setActiveLoginProvider('claude');
|
||||
const handleCursorLogin = () => setActiveLoginProvider('cursor');
|
||||
const handleCodexLogin = () => setActiveLoginProvider('codex');
|
||||
|
||||
const handleLoginComplete = (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
if (activeLoginProvider === 'claude') {
|
||||
checkClaudeAuthStatus();
|
||||
} else if (activeLoginProvider === 'cursor') {
|
||||
checkCursorAuthStatus();
|
||||
} else if (activeLoginProvider === 'codex') {
|
||||
checkCodexAuthStatus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = async () => {
|
||||
setError('');
|
||||
|
||||
// Step 0: Git config validation and submission
|
||||
if (currentStep === 0) {
|
||||
if (!gitName.trim() || !gitEmail.trim()) {
|
||||
setError('Both git name and email are required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(gitEmail)) {
|
||||
setError('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Save git config to backend (which will also apply git config --global)
|
||||
const response = await authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gitName, gitEmail })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to save git configuration');
|
||||
}
|
||||
|
||||
setCurrentStep(currentStep + 1);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const handlePrevStep = () => {
|
||||
setError('');
|
||||
setCurrentStep(currentStep - 1);
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/user/complete-onboarding', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to complete onboarding');
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Git Configuration',
|
||||
description: 'Set up your git identity for commits',
|
||||
icon: GitBranch,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
title: 'Connect Agents',
|
||||
description: 'Connect your AI coding assistants',
|
||||
icon: LogIn,
|
||||
required: false
|
||||
}
|
||||
];
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<GitBranch className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">Git Configuration</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your git identity to ensure proper attribution for your commits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="gitName" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<User className="w-4 h-4" />
|
||||
Git Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="gitName"
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This will be used as: git config --global user.name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="gitEmail" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
Git Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="gitEmail"
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This will be used as: git config --global user.email
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">Connect Your AI Agents</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Login to one or more AI coding assistants. All are optional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Cards Grid */}
|
||||
<div className="space-y-3">
|
||||
{/* Claude */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
claudeAuthStatus.authenticated
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||
<SessionProviderLogo provider="claude" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
Claude Code
|
||||
{claudeAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{claudeAuthStatus.loading ? 'Checking...' :
|
||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!claudeAuthStatus.authenticated && !claudeAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleClaudeLogin}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cursor */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
cursorAuthStatus.authenticated
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||
<SessionProviderLogo provider="cursor" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
Cursor
|
||||
{cursorAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cursorAuthStatus.loading ? 'Checking...' :
|
||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!cursorAuthStatus.authenticated && !cursorAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleCursorLogin}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Codex */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
codexAuthStatus.authenticated
|
||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<SessionProviderLogo provider="codex" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
OpenAI Codex
|
||||
{codexAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{codexAuthStatus.loading ? 'Checking...' :
|
||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!codexAuthStatus.authenticated && !codexAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleCodexLogin}
|
||||
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||
<p>You can configure these later in Settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isStepValid = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
|
||||
case 1:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
|
||||
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
|
||||
'bg-background border-border text-muted-foreground'
|
||||
}`}>
|
||||
{index < currentStep ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : typeof step.icon === 'function' ? (
|
||||
<step.icon />
|
||||
) : (
|
||||
<step.icon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p className={`text-sm font-medium ${
|
||||
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.required && (
|
||||
<span className="text-xs text-red-500">Required</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||
}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="bg-card rounded-lg shadow-lg border border-border p-8">
|
||||
{renderStepContent()}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-border">
|
||||
<button
|
||||
onClick={handlePrevStep}
|
||||
disabled={currentStep === 0 || isSubmitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{currentStep < steps.length - 1 ? (
|
||||
<button
|
||||
onClick={handleNextStep}
|
||||
disabled={!isStepValid() || isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Completing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Complete Setup
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeLoginProvider && (
|
||||
<LoginModal
|
||||
isOpen={!!activeLoginProvider}
|
||||
onClose={() => setActiveLoginProvider(null)}
|
||||
provider={activeLoginProvider}
|
||||
project={selectedProject}
|
||||
onComplete={handleLoginComplete}
|
||||
isOnboarding={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
875
src/components/ProjectCreationWizard.jsx
Normal file
@@ -0,0 +1,875 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const { t } = useTranslation();
|
||||
// Wizard state
|
||||
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
|
||||
const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
|
||||
|
||||
// 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);
|
||||
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
|
||||
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
|
||||
const [browserFolders, setBrowserFolders] = useState([]);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [cloneProgress, setCloneProgress] = useState('');
|
||||
|
||||
// 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, excluding exact match
|
||||
const filtered = data.suggestions.filter(s =>
|
||||
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
|
||||
s.path.toLowerCase() !== 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(t('projectWizard.errors.selectType'));
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
if (!workspacePath.trim()) {
|
||||
setError(t('projectWizard.errors.providePath'));
|
||||
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);
|
||||
setCloneProgress('');
|
||||
|
||||
try {
|
||||
if (workspaceType === 'new' && githubUrl) {
|
||||
const params = new URLSearchParams({
|
||||
path: workspacePath.trim(),
|
||||
githubUrl: githubUrl.trim(),
|
||||
});
|
||||
|
||||
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||
params.append('githubTokenId', selectedGithubToken);
|
||||
} else if (tokenMode === 'new' && newGithubToken) {
|
||||
params.append('newGithubToken', newGithubToken.trim());
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setCloneProgress(data.message);
|
||||
} else if (data.type === 'complete') {
|
||||
eventSource.close();
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
onClose();
|
||||
resolve();
|
||||
} else if (data.type === 'error') {
|
||||
eventSource.close();
|
||||
reject(new Error(data.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
reject(new Error('Connection lost during clone'));
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
workspaceType,
|
||||
path: workspacePath.trim(),
|
||||
};
|
||||
|
||||
const response = await api.createWorkspace(payload);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
|
||||
}
|
||||
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
setError(error.message || t('projectWizard.errors.failedToCreate'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPathSuggestion = (suggestion) => {
|
||||
setWorkspacePath(suggestion.path);
|
||||
setShowPathDropdown(false);
|
||||
};
|
||||
|
||||
const openFolderBrowser = async () => {
|
||||
setShowFolderBrowser(true);
|
||||
await loadBrowserFolders('~');
|
||||
};
|
||||
|
||||
const loadBrowserFolders = async (path) => {
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
const response = await api.browseFilesystem(path);
|
||||
const data = await response.json();
|
||||
setBrowserCurrentPath(data.path || path);
|
||||
setBrowserFolders(data.suggestions || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
} finally {
|
||||
setLoadingFolders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectFolder = (folderPath, advanceToConfirm = false) => {
|
||||
setWorkspacePath(folderPath);
|
||||
setShowFolderBrowser(false);
|
||||
if (advanceToConfirm) {
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToFolder = async (folderPath) => {
|
||||
await loadBrowserFolders(folderPath);
|
||||
};
|
||||
|
||||
const createNewFolder = async () => {
|
||||
if (!newFolderName.trim()) return;
|
||||
setCreatingFolder(true);
|
||||
setError(null);
|
||||
try {
|
||||
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
|
||||
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
|
||||
const response = await api.createFolder(folderPath);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||
}
|
||||
setNewFolderName('');
|
||||
setShowNewFolderInput(false);
|
||||
await loadBrowserFolders(data.path || folderPath);
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||
} finally {
|
||||
setCreatingFolder(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">
|
||||
{t('projectWizard.title')}
|
||||
</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 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.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">
|
||||
{t('projectWizard.step1.question')}
|
||||
</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">
|
||||
{t('projectWizard.step1.existing.title')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step1.existing.description')}
|
||||
</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">
|
||||
{t('projectWizard.step1.new.title')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step1.new.description')}
|
||||
</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' ? t('projectWizard.step2.existingPath') : t('projectWizard.step2.newPath')}
|
||||
</label>
|
||||
<div className="relative flex gap-2">
|
||||
<div className="flex-1 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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openFolderBrowser}
|
||||
className="px-3"
|
||||
title="Browse folders"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step2.existingHelp')
|
||||
: t('projectWizard.step2.newHelp')}
|
||||
</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">
|
||||
{t('projectWizard.step2.githubUrl')}
|
||||
</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">
|
||||
{t('projectWizard.step2.githubHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
|
||||
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
|
||||
<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">
|
||||
{t('projectWizard.step2.githubAuth')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step2.githubAuthHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingTokens ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('projectWizard.step2.loadingTokens')}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{t('projectWizard.step2.storedToken')}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{t('projectWizard.step2.newToken')}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{t('projectWizard.step2.nonePublic')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tokenMode === 'stored' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('projectWizard.step2.selectToken')}
|
||||
</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="">{t('projectWizard.step2.selectTokenPlaceholder')}</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">
|
||||
{t('projectWizard.step2.newToken')}
|
||||
</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">
|
||||
{t('projectWizard.step2.tokenHelp')}
|
||||
</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">
|
||||
{t('projectWizard.step2.publicRepoInfo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('projectWizard.step2.optionalTokenPublic')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projectWizard.step2.noTokensHelp')}
|
||||
</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">
|
||||
{t('projectWizard.step3.reviewConfig')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.workspaceType')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.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">{t('projectWizard.step3.cloneFrom')}</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">{t('projectWizard.step3.authentication')}</span>
|
||||
<span className="text-xs text-gray-900 dark:text-white">
|
||||
{tokenMode === 'stored' && selectedGithubToken
|
||||
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||
: tokenMode === 'new' && newGithubToken
|
||||
? t('projectWizard.step3.usingProvidedToken')
|
||||
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
|
||||
? t('projectWizard.step3.sshKey', 'SSH Key')
|
||||
: t('projectWizard.step3.noAuthentication')}
|
||||
</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">
|
||||
{isCreating && cloneProgress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
|
||||
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
|
||||
{cloneProgress}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingInfo')
|
||||
: githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
</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 ? (
|
||||
t('projectWizard.buttons.cancel')
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('projectWizard.buttons.back')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={step === 3 ? handleCreate : handleNext}
|
||||
disabled={isCreating || (step === 1 && !workspaceType)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
|
||||
</>
|
||||
) : step === 3 ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{t('projectWizard.buttons.createProject')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('projectWizard.buttons.next')}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder Browser Modal */}
|
||||
{showFolderBrowser && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
{/* Browser Header */}
|
||||
<div className="flex items-center justify-between p-4 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">
|
||||
<FolderOpen 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">
|
||||
Select Folder
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showHiddenFolders
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
|
||||
>
|
||||
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showNewFolderInput
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Create new folder"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFolderBrowser(false)}
|
||||
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"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Folder Input */}
|
||||
{showNewFolderInput && (
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="New folder name"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewFolder();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createNewFolder}
|
||||
disabled={!newFolderName.trim() || creatingFolder}
|
||||
>
|
||||
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loadingFolders ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
|
||||
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
|
||||
let parentPath;
|
||||
if (lastSlash <= 0) {
|
||||
parentPath = '/';
|
||||
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
|
||||
parentPath = browserCurrentPath.substring(0, 3);
|
||||
} else {
|
||||
parentPath = browserCurrentPath.substring(0, lastSlash);
|
||||
}
|
||||
navigateToFolder(parentPath);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5 text-gray-400" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Folders */}
|
||||
{browserFolders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No subfolders found
|
||||
</div>
|
||||
) : (
|
||||
browserFolders
|
||||
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
|
||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||
.map((folder, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigateToFolder(folder.path)}
|
||||
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||
>
|
||||
<FolderPlus className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
|
||||
className="text-xs px-3"
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Browser Footer with Current Path */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
|
||||
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
|
||||
{browserCurrentPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowFolderBrowser(false);
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
|
||||
>
|
||||
Use this folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCreationWizard;
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import Onboarding from './Onboarding';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
@@ -24,7 +26,19 @@ const LoadingScreen = () => (
|
||||
);
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup } = useAuth();
|
||||
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
if (IS_PLATFORM) {
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <Onboarding onComplete={refreshOnboardingStatus} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
@@ -38,6 +52,10 @@ const ProtectedRoute = ({ children }) => {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <Onboarding onComplete={refreshOnboardingStatus} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
Settings2,
|
||||
Moon,
|
||||
Sun,
|
||||
@@ -12,67 +12,233 @@ import {
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Languages
|
||||
Languages,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const QuickSettingsPanel = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
autoExpandTools,
|
||||
onAutoExpandChange,
|
||||
showRawParameters,
|
||||
onShowRawParametersChange,
|
||||
showThinking,
|
||||
onShowThinkingChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
sendByCtrlEnter,
|
||||
onSendByCtrlEnterChange,
|
||||
isMobile
|
||||
}) => {
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
|
||||
import { useDeviceSettings } from '../hooks/useDeviceSettings';
|
||||
|
||||
|
||||
const QuickSettingsPanel = () => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [whisperMode, setWhisperMode] = useState(() => {
|
||||
return localStorage.getItem('whisperMode') || 'default';
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setLocalIsOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
|
||||
const handleToggle = () => {
|
||||
const newState = !localIsOpen;
|
||||
setLocalIsOpen(newState);
|
||||
onToggle(newState);
|
||||
const { preferences, setPreference } = useUiPreferences();
|
||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
||||
|
||||
// Draggable handle state
|
||||
const [handlePosition, setHandlePosition] = useState(() => {
|
||||
const saved = localStorage.getItem('quickSettingsHandlePosition');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.y ?? 50;
|
||||
} catch {
|
||||
// Remove corrupted data
|
||||
localStorage.removeItem('quickSettingsHandlePosition');
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
return 50; // Default to 50% (middle of screen)
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartY, setDragStartY] = useState(0);
|
||||
const [dragStartPosition, setDragStartPosition] = useState(0);
|
||||
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
|
||||
const handleRef = useRef(null);
|
||||
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
|
||||
const dragThreshold = 5; // Pixels to move before it's considered a drag
|
||||
|
||||
// Save handle position to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
|
||||
}, [handlePosition]);
|
||||
|
||||
// Calculate position from percentage
|
||||
const getPositionStyle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
// On mobile, convert percentage to pixels from bottom
|
||||
const bottomPixels = (window.innerHeight * handlePosition) / 100;
|
||||
return { bottom: `${bottomPixels}px` };
|
||||
} else {
|
||||
// On desktop, use top with percentage
|
||||
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
|
||||
}
|
||||
}, [handlePosition, isMobile]);
|
||||
|
||||
// Handle mouse/touch start
|
||||
const handleDragStart = useCallback((e) => {
|
||||
// Don't prevent default yet - we want to allow click if no drag happens
|
||||
e.stopPropagation();
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
setDragStartY(clientY);
|
||||
setDragStartPosition(handlePosition);
|
||||
setHasMoved(false);
|
||||
setIsDragging(false); // Don't set dragging until threshold is passed
|
||||
}, [handlePosition]);
|
||||
|
||||
// Handle mouse/touch move
|
||||
const handleDragMove = useCallback((e) => {
|
||||
if (dragStartY === 0) return; // Not in a potential drag
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
const deltaY = Math.abs(clientY - dragStartY);
|
||||
|
||||
// Check if we've moved past threshold
|
||||
if (!isDragging && deltaY > dragThreshold) {
|
||||
setIsDragging(true);
|
||||
setHasMoved(true);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Prevent body scroll on mobile during drag
|
||||
if (e.type.includes('touch')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDragging) return;
|
||||
|
||||
// Prevent scrolling on touch move
|
||||
if (e.type.includes('touch')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const actualDeltaY = clientY - dragStartY;
|
||||
|
||||
// For top-based positioning (desktop), moving down increases top percentage
|
||||
// For bottom-based positioning (mobile), we need to invert
|
||||
let percentageDelta;
|
||||
if (isMobile) {
|
||||
// On mobile, moving down should decrease bottom position (increase percentage from top)
|
||||
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
|
||||
} else {
|
||||
// On desktop, moving down should increase top position
|
||||
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
let newPosition = dragStartPosition + percentageDelta;
|
||||
|
||||
// Apply constraints
|
||||
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
|
||||
|
||||
setHandlePosition(newPosition);
|
||||
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
|
||||
|
||||
// Handle mouse/touch end
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setDragStartY(0);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// Restore body scroll on mobile
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}, []);
|
||||
|
||||
// Cleanup body styles on unmount in case component unmounts while dragging
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up global event listeners for drag
|
||||
useEffect(() => {
|
||||
if (dragStartY !== 0) {
|
||||
// Mouse events
|
||||
const handleMouseMove = (e) => handleDragMove(e);
|
||||
const handleMouseUp = () => handleDragEnd();
|
||||
|
||||
// Touch events
|
||||
const handleTouchMove = (e) => handleDragMove(e);
|
||||
const handleTouchEnd = () => handleDragEnd();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [dragStartY, handleDragMove, handleDragEnd]);
|
||||
|
||||
const handleToggle = (e) => {
|
||||
// Don't toggle if user was dragging
|
||||
if (hasMoved) {
|
||||
e.preventDefault();
|
||||
setHasMoved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen((previous) => !previous);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull Tab */}
|
||||
<div
|
||||
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
|
||||
localIsOpen ? 'right-64' : 'right-0'
|
||||
} z-50 transition-all duration-150 ease-out`}
|
||||
{/* Pull Tab - Combined drag handle and toggle button */}
|
||||
<button
|
||||
ref={handleRef}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={(e) => {
|
||||
// Start drag on mousedown
|
||||
handleDragStart(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
// Start drag on touchstart
|
||||
handleDragStart(e);
|
||||
}}
|
||||
className={`fixed ${
|
||||
isOpen ? 'right-64' : 'right-0'
|
||||
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
|
||||
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
|
||||
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
} touch-none`}
|
||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
|
||||
>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg"
|
||||
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
>
|
||||
{localIsOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isDragging ? (
|
||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||
) : isOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
localIsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -80,110 +246,115 @@ const QuickSettingsPanel = ({
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
Quick Settings
|
||||
{t('quickSettings.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-white dark:bg-gray-900 ${isMobile ? 'pb-20' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{/* Appearance Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Appearance</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
|
||||
Dark Mode
|
||||
{t('quickSettings.darkMode')}
|
||||
</span>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div>
|
||||
<LanguageSelector compact={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Display Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Tool Display</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Auto-expand tools
|
||||
{t('quickSettings.autoExpandTools')}
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
onChange={(e) => setPreference('autoExpandTools', 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 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>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Show raw parameters
|
||||
{t('quickSettings.showRawParameters')}
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
onChange={(e) => setPreference('showRawParameters', 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 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>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Show thinking
|
||||
{t('quickSettings.showThinking')}
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
onChange={(e) => setPreference('showThinking', 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 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>
|
||||
{/* View Options */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">View Options</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Auto-scroll to bottom
|
||||
{t('quickSettings.autoScrollToBottom')}
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
onChange={(e) => setPreference('autoScrollToBottom', 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 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>
|
||||
|
||||
{/* Input Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Send by Ctrl+Enter
|
||||
{t('quickSettings.sendByCtrlEnter')}
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
onChange={(e) => setPreference('sendByCtrlEnter', 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 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">
|
||||
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
|
||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper Dictation Settings - HIDDEN */}
|
||||
<div className="space-y-2" style={{ display: 'none' }}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
@@ -202,10 +373,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Default Mode
|
||||
{t('quickSettings.whisper.modes.default')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Direct transcription of your speech
|
||||
{t('quickSettings.whisper.modes.defaultDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -226,10 +397,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Prompt Enhancement
|
||||
{t('quickSettings.whisper.modes.prompt')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Transform rough ideas into clear, detailed AI prompts
|
||||
{t('quickSettings.whisper.modes.promptDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -250,10 +421,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Vibe Mode
|
||||
{t('quickSettings.whisper.modes.vibe')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format ideas as clear agent instructions with details
|
||||
{t('quickSettings.whisper.modes.vibeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -264,7 +435,7 @@ const QuickSettingsPanel = ({
|
||||
</div>
|
||||
|
||||
{/* Backdrop */}
|
||||
{localIsOpen && (
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const SetupForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -48,7 +46,7 @@ const SetupForm = () => {
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
// CSS to remove xterm focus outline
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
outline: none !important;
|
||||
}
|
||||
.xterm:focus .xterm-screen {
|
||||
outline: none !important;
|
||||
}
|
||||
.xterm-screen:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.type = 'text/css';
|
||||
styleSheet.innerText = xtermStyles;
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Global store for shell sessions to persist across tab switches
|
||||
const shellSessions = new Map();
|
||||
|
||||
function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) {
|
||||
const terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
const ws = useRef(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [lastSessionId, setLastSessionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Connect to shell function
|
||||
const connectToShell = () => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
// Start the WebSocket connection
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// Disconnect from shell function
|
||||
const disconnectFromShell = () => {
|
||||
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
|
||||
// Clear terminal content completely
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
// Restart shell function
|
||||
const restartShell = () => {
|
||||
setIsRestarting(true);
|
||||
|
||||
// Clear ALL session storage for this project to force fresh start
|
||||
const sessionKeys = Array.from(shellSessions.keys()).filter(key =>
|
||||
key.includes(selectedProject.name)
|
||||
);
|
||||
sessionKeys.forEach(key => shellSessions.delete(key));
|
||||
|
||||
|
||||
// Close existing WebSocket
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
|
||||
// Clear and dispose existing terminal
|
||||
if (terminal.current) {
|
||||
|
||||
// Dispose terminal immediately without writing text
|
||||
terminal.current.dispose();
|
||||
terminal.current = null;
|
||||
fitAddon.current = null;
|
||||
}
|
||||
|
||||
// Reset states
|
||||
setIsConnected(false);
|
||||
setIsInitialized(false);
|
||||
|
||||
|
||||
// Force re-initialization after cleanup
|
||||
setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Watch for session changes and restart shell
|
||||
useEffect(() => {
|
||||
const currentSessionId = selectedSession?.id || null;
|
||||
|
||||
|
||||
// Disconnect when session changes (user will need to manually reconnect)
|
||||
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
|
||||
|
||||
// Disconnect from current shell
|
||||
disconnectFromShell();
|
||||
|
||||
// Clear stored sessions for this project
|
||||
const allKeys = Array.from(shellSessions.keys());
|
||||
allKeys.forEach(key => {
|
||||
if (key.includes(selectedProject.name)) {
|
||||
shellSessions.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLastSessionId(currentSessionId);
|
||||
}, [selectedSession?.id, isInitialized]);
|
||||
|
||||
// Initialize terminal when component mounts
|
||||
useEffect(() => {
|
||||
|
||||
if (!terminalRef.current || !selectedProject || isRestarting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create session key for this project/session combination
|
||||
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
|
||||
|
||||
// Check if we have an existing session
|
||||
const existingSession = shellSessions.get(sessionKey);
|
||||
if (existingSession && !terminal.current) {
|
||||
|
||||
try {
|
||||
// Reuse existing terminal
|
||||
terminal.current = existingSession.terminal;
|
||||
fitAddon.current = existingSession.fitAddon;
|
||||
ws.current = existingSession.ws;
|
||||
setIsConnected(existingSession.isConnected);
|
||||
|
||||
// Reattach to DOM - dispose existing element first if needed
|
||||
if (terminal.current.element && terminal.current.element.parentNode) {
|
||||
terminal.current.element.parentNode.removeChild(terminal.current.element);
|
||||
}
|
||||
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
// Send terminal size to backend after reattaching
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Clear the broken session and continue to create a new one
|
||||
shellSessions.delete(sessionKey);
|
||||
terminal.current = null;
|
||||
fitAddon.current = null;
|
||||
ws.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Initialize new terminal
|
||||
terminal.current = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true, // Required for clipboard addon
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
scrollback: 10000,
|
||||
tabStopWidth: 4,
|
||||
// Enable full color support
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
// Enhanced theme with full 16-color ANSI support + true colors
|
||||
theme: {
|
||||
// Basic colors
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selection: '#264f78',
|
||||
selectionForeground: '#ffffff',
|
||||
|
||||
// Standard ANSI colors (0-7)
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
|
||||
// Bright ANSI colors (8-15)
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
|
||||
// Extended colors for better Claude output
|
||||
extendedAnsi: [
|
||||
// 16-color palette extension for 256-color support
|
||||
'#000000', '#800000', '#008000', '#808000',
|
||||
'#000080', '#800080', '#008080', '#c0c0c0',
|
||||
'#808080', '#ff0000', '#00ff00', '#ffff00',
|
||||
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
fitAddon.current = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const webglAddon = new WebglAddon();
|
||||
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.loadAddon(clipboardAddon);
|
||||
|
||||
try {
|
||||
terminal.current.loadAddon(webglAddon);
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
// Wait for terminal to be fully rendered, then fit
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Add keyboard shortcuts for copy/paste
|
||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||
// Ctrl+C or Cmd+C for copy (when text is selected)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V or Cmd+V for paste
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: text
|
||||
}));
|
||||
}
|
||||
}).catch(err => {
|
||||
// Failed to read clipboard
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ensure terminal takes full space and notify backend of size
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
// Send terminal size to backend after fitting
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
// Handle terminal input
|
||||
terminal.current.onData((data) => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: data
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Add resize observer to handle container size changes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
setTimeout(() => {
|
||||
fitAddon.current.fit();
|
||||
// Send updated terminal size to backend after resize
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
if (terminalRef.current) {
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
|
||||
// Store session for reuse instead of disposing
|
||||
if (terminal.current && selectedProject) {
|
||||
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
|
||||
|
||||
try {
|
||||
shellSessions.set(sessionKey, {
|
||||
terminal: terminal.current,
|
||||
fitAddon: fitAddon.current,
|
||||
ws: ws.current,
|
||||
isConnected: isConnected
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
|
||||
|
||||
// Fit terminal when tab becomes active
|
||||
useEffect(() => {
|
||||
if (!isActive || !isInitialized) return;
|
||||
|
||||
// Fit terminal when tab becomes active and notify backend
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
// Send terminal size to backend after tab activation
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}, [isActive, isInitialized]);
|
||||
|
||||
// WebSocket connection function (called manually)
|
||||
const connectWebSocket = async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch server configuration to get the correct WebSocket URL
|
||||
let wsBaseUrl;
|
||||
try {
|
||||
const configResponse = await fetch('/api/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const config = await configResponse.json();
|
||||
wsBaseUrl = config.wsUrl;
|
||||
|
||||
// If the config returns localhost but we're not on localhost, use current host but with API server port
|
||||
if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// For development, API server is typically on port 3002 when Vite is on 3001
|
||||
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
} catch (error) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// For development, API server is typically on port 3002 when Vite is on 3001
|
||||
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
|
||||
// Wait for terminal to be ready, then fit and send dimensions
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
// Force a fit to ensure proper dimensions
|
||||
fitAddon.current.fit();
|
||||
|
||||
// Wait a bit more for fit to complete, then send dimensions
|
||||
setTimeout(() => {
|
||||
const initPayload = {
|
||||
type: 'init',
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: isPlainShell ? null : selectedSession?.id,
|
||||
hasSession: isPlainShell ? false : !!selectedSession,
|
||||
provider: isPlainShell ? 'plain-shell' : (selectedSession?.__provider || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommand,
|
||||
isPlainShell: isPlainShell
|
||||
};
|
||||
|
||||
console.log('Shell init payload:', initPayload);
|
||||
|
||||
ws.current.send(JSON.stringify(initPayload));
|
||||
|
||||
// Also send resize message immediately after init
|
||||
setTimeout(() => {
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}, 100);
|
||||
}, 50);
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'output') {
|
||||
// Check for URLs in the output and make them clickable
|
||||
const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g;
|
||||
let output = data.data;
|
||||
|
||||
// Find URLs in the text (excluding ANSI escape sequences)
|
||||
const urls = [];
|
||||
let match;
|
||||
while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
|
||||
if (isPlainShell && onProcessComplete) {
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessComplete(0); // Success
|
||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||
if (exitCode !== 0) {
|
||||
onProcessComplete(exitCode); // Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If URLs found, log them for potential opening
|
||||
|
||||
terminal.current.write(output);
|
||||
} else if (data.type === 'url_open') {
|
||||
// Handle explicit URL opening requests from server
|
||||
window.open(data.url, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
|
||||
// Clear terminal content when connection closes
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
|
||||
}
|
||||
|
||||
// Don't auto-reconnect anymore - user must manually connect
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
} catch (error) {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
|
||||
<p>Choose a project to open an interactive shell in that directory</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
{selectedSession && (() => {
|
||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
return (
|
||||
<span className="text-xs text-blue-300">
|
||||
({displaySessionName.slice(0, 30)}...)
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{!selectedSession && (
|
||||
<span className="text-xs text-gray-400">(New Session)</span>
|
||||
)}
|
||||
{!isInitialized && (
|
||||
<span className="text-xs text-yellow-400">(Initializing...)</span>
|
||||
)}
|
||||
{isRestarting && (
|
||||
<span className="text-xs text-blue-400">(Restarting...)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{isConnected && (
|
||||
<button
|
||||
onClick={disconnectFromShell}
|
||||
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
|
||||
title="Disconnect from shell"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={restartShell}
|
||||
disabled={isRestarting || isConnected}
|
||||
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
title="Restart Shell (disconnect first)"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 p-2 overflow-hidden relative">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
|
||||
{/* Loading state */}
|
||||
{!isInitialized && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-white">Loading terminal...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect button when not connected */}
|
||||
{isInitialized && !isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<button
|
||||
onClick={connectToShell}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
||||
title="Connect to shell"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Continue in Shell</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
selectedSession ?
|
||||
(() => {
|
||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
|
||||
})() :
|
||||
'Start a new Claude session'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connecting state */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Shell;
|
||||
@@ -1,106 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Shell from './Shell.jsx';
|
||||
|
||||
/**
|
||||
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
||||
* Provides a flexible API for both standalone and session-based usage.
|
||||
*
|
||||
* @param {Object} project - Project object with name, fullPath/path, displayName
|
||||
* @param {Object} session - Session object (optional, for tab usage)
|
||||
* @param {string} command - Initial command to run (optional)
|
||||
* @param {boolean} isActive - Whether the shell is active (for tab usage, default: true)
|
||||
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
||||
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
||||
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
||||
* @param {function} onClose - Callback for close button (optional)
|
||||
* @param {string} title - Custom header title (optional)
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
||||
* @param {boolean} compact - Use compact layout (default: false)
|
||||
*/
|
||||
function StandaloneShell({
|
||||
project,
|
||||
session = null,
|
||||
command = null,
|
||||
isActive = true,
|
||||
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
|
||||
autoConnect = true,
|
||||
onComplete = null,
|
||||
onClose = null,
|
||||
title = null,
|
||||
className = "",
|
||||
showHeader = true,
|
||||
compact = false
|
||||
}) {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
// Auto-detect isPlainShell based on props
|
||||
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||
|
||||
// Handle process completion
|
||||
const handleProcessComplete = (exitCode) => {
|
||||
setIsCompleted(true);
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${className}`}>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
|
||||
<p>A project is required to open a shell</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
{/* Optional custom header */}
|
||||
{showHeader && title && (
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
||||
{isCompleted && (
|
||||
<span className="text-xs text-green-400">(Completed)</span>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shell component wrapper */}
|
||||
<div className="flex-1">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={session}
|
||||
isActive={isActive}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StandaloneShell;
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const TaskDetail = ({
|
||||
task,
|
||||
@@ -79,7 +80,7 @@ const TaskDetail = ({
|
||||
};
|
||||
|
||||
const copyTaskId = () => {
|
||||
navigator.clipboard.writeText(task.id.toString());
|
||||
copyTextToClipboard(task.id.toString());
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import TaskCard from './TaskCard';
|
||||
import CreateTaskModal from './CreateTaskModal';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import Shell from './Shell';
|
||||
import Shell from './shell/view/Shell';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TaskList = ({
|
||||
tasks = [],
|
||||
@@ -31,13 +32,19 @@ const TaskList = ({
|
||||
const [showHelpGuide, setShowHelpGuide] = useState(false);
|
||||
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
|
||||
const [showPRDDropdown, setShowPRDDropdown] = useState(false);
|
||||
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
// Close PRD dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showPRDDropdown && !event.target.closest('.relative')) {
|
||||
if (
|
||||
showPRDDropdown &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target)
|
||||
) {
|
||||
setShowPRDDropdown(false);
|
||||
}
|
||||
};
|
||||
@@ -46,6 +53,31 @@ const TaskList = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showPRDDropdown]);
|
||||
|
||||
const loadPRDOptions = async (prd, closeDropdown = false) => {
|
||||
if (!currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
|
||||
if (response.ok) {
|
||||
const prdData = await response.json();
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: prdData.content,
|
||||
isExisting: true
|
||||
});
|
||||
if (closeDropdown) {
|
||||
setShowPRDDropdown(false);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load PRD:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique status values from tasks
|
||||
const statuses = useMemo(() => {
|
||||
const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
|
||||
@@ -143,45 +175,45 @@ const TaskList = ({
|
||||
// Organize tasks by status for Kanban view
|
||||
const kanbanColumns = useMemo(() => {
|
||||
const allColumns = [
|
||||
{
|
||||
id: 'pending',
|
||||
title: '📋 To Do',
|
||||
status: 'pending',
|
||||
{
|
||||
id: 'pending',
|
||||
title: t('kanban.pending'),
|
||||
status: 'pending',
|
||||
color: 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700',
|
||||
headerColor: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
title: '🚀 In Progress',
|
||||
status: 'in-progress',
|
||||
{
|
||||
id: 'in-progress',
|
||||
title: t('kanban.inProgress'),
|
||||
status: 'in-progress',
|
||||
color: 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700',
|
||||
headerColor: 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200'
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: '✅ Done',
|
||||
status: 'done',
|
||||
{
|
||||
id: 'done',
|
||||
title: t('kanban.done'),
|
||||
status: 'done',
|
||||
color: 'bg-emerald-50 dark:bg-emerald-900/50 border-emerald-200 dark:border-emerald-700',
|
||||
headerColor: 'bg-emerald-100 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200'
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
title: '🚫 Blocked',
|
||||
status: 'blocked',
|
||||
{
|
||||
id: 'blocked',
|
||||
title: t('kanban.blocked'),
|
||||
status: 'blocked',
|
||||
color: 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-700',
|
||||
headerColor: 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200'
|
||||
},
|
||||
{
|
||||
id: 'deferred',
|
||||
title: '⏳ Deferred',
|
||||
status: 'deferred',
|
||||
{
|
||||
id: 'deferred',
|
||||
title: t('kanban.deferred'),
|
||||
status: 'deferred',
|
||||
color: 'bg-amber-50 dark:bg-amber-900/50 border-amber-200 dark:border-amber-700',
|
||||
headerColor: 'bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-200'
|
||||
},
|
||||
{
|
||||
id: 'cancelled',
|
||||
title: '❌ Cancelled',
|
||||
status: 'cancelled',
|
||||
{
|
||||
id: 'cancelled',
|
||||
title: t('kanban.cancelled'),
|
||||
status: 'cancelled',
|
||||
color: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700',
|
||||
headerColor: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}
|
||||
@@ -199,7 +231,7 @@ const TaskList = ({
|
||||
...column,
|
||||
tasks: filteredAndSortedTasks.filter(task => task.status === column.status)
|
||||
}));
|
||||
}, [filteredAndSortedTasks]);
|
||||
}, [filteredAndSortedTasks, t]);
|
||||
|
||||
const handleSortChange = (newSortBy) => {
|
||||
if (sortBy === newSortBy) {
|
||||
@@ -236,26 +268,26 @@ const TaskList = ({
|
||||
<Settings className="w-12 h-12 mx-auto mb-4" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
TaskMaster AI is not configured
|
||||
{t('notConfigured.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
TaskMaster helps break down complex projects into manageable tasks with AI-powered assistance
|
||||
{t('notConfigured.description')}
|
||||
</p>
|
||||
|
||||
|
||||
{/* What is TaskMaster section */}
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-left">
|
||||
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-3">
|
||||
🎯 What is TaskMaster?
|
||||
{t('notConfigured.whatIsTitle')}
|
||||
</h4>
|
||||
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>• <strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks</p>
|
||||
<p>• <strong>PRD Templates:</strong> Generate tasks from Product Requirements Documents</p>
|
||||
<p>• <strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
|
||||
<p>• <strong>Progress Visualization:</strong> Kanban boards and detailed task analytics</p>
|
||||
<p>• <strong>CLI Integration:</strong> Use taskmaster commands for advanced workflows</p>
|
||||
<p>• {t('notConfigured.features.aiPowered')}</p>
|
||||
<p>• {t('notConfigured.features.prdTemplates')}</p>
|
||||
<p>• {t('notConfigured.features.dependencyTracking')}</p>
|
||||
<p>• {t('notConfigured.features.progressVisualization')}</p>
|
||||
<p>• {t('notConfigured.features.cliIntegration')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsTaskMasterComplete(false); // Reset completion state
|
||||
@@ -264,7 +296,7 @@ const TaskList = ({
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Initialize TaskMaster AI
|
||||
{t('notConfigured.initializeButton')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -276,8 +308,8 @@ const TaskList = ({
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Getting Started with TaskMaster</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">TaskMaster is initialized! Here's what to do next:</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('gettingStarted.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -287,8 +319,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">1</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Create a Product Requirements Document (PRD)</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Discuss your project idea and create a PRD that describes what you want to build.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.createPRD.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{t('gettingStarted.steps.createPRD.description')}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowPRDEditor?.();
|
||||
@@ -296,34 +328,19 @@ const TaskList = ({
|
||||
className="inline-flex items-center gap-1 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-2 py-1 rounded hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Add PRD
|
||||
{t('gettingStarted.steps.createPRD.addButton')}
|
||||
</button>
|
||||
|
||||
|
||||
{/* Show existing PRDs if any */}
|
||||
{existingPRDs.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Existing PRDs:</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{t('gettingStarted.steps.createPRD.existingPRDs')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{existingPRDs.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Load the PRD content from the API
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
|
||||
if (response.ok) {
|
||||
const prdData = await response.json();
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: prdData.content,
|
||||
isExisting: true
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to load PRD:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD:', error);
|
||||
}
|
||||
onClick={() => {
|
||||
void loadPRDOptions(prd);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
@@ -341,8 +358,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">2</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Generate Tasks from PRD</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Once you have a PRD, ask your AI assistant to parse it and TaskMaster will automatically break it down into manageable tasks with implementation details.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.generateTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.generateTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,8 +367,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">3</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Analyze & Expand Tasks</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Ask your AI assistant to analyze task complexity and expand them into detailed subtasks for easier implementation.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.analyzeTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.analyzeTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,8 +376,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">4</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Start Building</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Ask your AI assistant to begin working on tasks, update their status, and add new tasks as your project evolves.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.startBuilding.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.startBuilding.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +393,7 @@ const TaskList = ({
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Add PRD
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,7 +401,7 @@ const TaskList = ({
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
💡 <strong>Tip:</strong> Start with a PRD to get the most out of TaskMaster's AI-powered task generation
|
||||
{t('gettingStarted.tip')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,8 +418,8 @@ const TaskList = ({
|
||||
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">TaskMaster Setup</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Interactive CLI for {currentProject?.displayName}</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('setupModal.title')}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('setupModal.subtitle', { projectName: currentProject?.displayName })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -464,10 +481,10 @@ const TaskList = ({
|
||||
{isTaskMasterComplete ? (
|
||||
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
TaskMaster setup completed! You can now close this window.
|
||||
{t('setupModal.completed')}
|
||||
</span>
|
||||
) : (
|
||||
"TaskMaster initialization will start automatically"
|
||||
t('setupModal.willStart')
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -485,12 +502,12 @@ const TaskList = ({
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
|
||||
isTaskMasterComplete
|
||||
? "bg-green-600 hover:bg-green-700 text-white"
|
||||
isTaskMasterComplete
|
||||
? "bg-green-600 hover:bg-green-700 text-white"
|
||||
: "text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
)}
|
||||
>
|
||||
{isTaskMasterComplete ? "Close & Continue" : "Close"}
|
||||
{isTaskMasterComplete ? t('setupModal.closeContinueButton') : t('setupModal.closeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,7 +527,7 @@ const TaskList = ({
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
placeholder={t('search.placeholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
@@ -529,7 +546,7 @@ const TaskList = ({
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
title="Kanban view"
|
||||
title={t('views.kanban')}
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -537,11 +554,11 @@ const TaskList = ({
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'p-2 rounded-md transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
title="List view"
|
||||
title={t('views.list')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -549,11 +566,11 @@ const TaskList = ({
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'p-2 rounded-md transition-colors',
|
||||
viewMode === 'grid'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
viewMode === 'grid'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
title="Grid view"
|
||||
title={t('views.grid')}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -570,7 +587,7 @@ const TaskList = ({
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
<span className="hidden sm:inline">{t('filters.button')}</span>
|
||||
<ChevronDown className={cn('w-4 h-4 transition-transform', showFilters && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
@@ -581,29 +598,29 @@ const TaskList = ({
|
||||
<button
|
||||
onClick={() => setShowHelpGuide(true)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors border border-gray-300 dark:border-gray-600"
|
||||
title="TaskMaster Getting Started Guide"
|
||||
title={t('buttons.help')}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* PRD Management */}
|
||||
<div className="relative">
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{existingPRDs.length > 0 ? (
|
||||
// Dropdown when PRDs exist
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPRDDropdown(!showPRDDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||
title={`${existingPRDs.length} PRD${existingPRDs.length > 1 ? 's' : ''} available`}
|
||||
title={t('buttons.prdsAvailable', { count: existingPRDs.length })}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">PRDs</span>
|
||||
<span className="hidden sm:inline">{t('buttons.prds')}</span>
|
||||
<span className="px-1.5 py-0.5 text-xs bg-purple-500 rounded-full min-w-[1.25rem] text-center">
|
||||
{existingPRDs.length}
|
||||
</span>
|
||||
<ChevronDown className={cn('w-3 h-3 transition-transform hidden sm:block', showPRDDropdown && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
|
||||
{showPRDDropdown && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-30">
|
||||
<div className="p-2">
|
||||
@@ -615,31 +632,18 @@ const TaskList = ({
|
||||
className="w-full text-left px-3 py-2 text-sm font-medium text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create New PRD
|
||||
{t('buttons.createNewPRD')}
|
||||
</button>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-3 py-1 font-medium">Existing PRDs:</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-3 py-1 font-medium">{t('gettingStarted.steps.createPRD.existingPRDs')}</div>
|
||||
{existingPRDs.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
|
||||
if (response.ok) {
|
||||
const prdData = await response.json();
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: prdData.content,
|
||||
isExisting: true
|
||||
});
|
||||
setShowPRDDropdown(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD:', error);
|
||||
}
|
||||
onClick={() => {
|
||||
void loadPRDOptions(prd, true);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
|
||||
title={`Modified: ${new Date(prd.modified).toLocaleDateString()}`}
|
||||
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="truncate">{prd.name}</span>
|
||||
@@ -656,10 +660,10 @@ const TaskList = ({
|
||||
onShowPRDEditor?.();
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||
title="Create Product Requirements Document"
|
||||
title={t('buttons.addPRD')}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Add PRD</span>
|
||||
<span className="hidden sm:inline">{t('buttons.addPRD')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -669,10 +673,10 @@ const TaskList = ({
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
|
||||
title="Add a new task"
|
||||
title={t('buttons.addTask')}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Add Task</span>
|
||||
<span className="hidden sm:inline">{t('buttons.addTask')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -687,17 +691,17 @@ const TaskList = ({
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
{t('filters.status')}
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="all">{t('filters.allStatuses')}</option>
|
||||
{statuses.map(status => (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ')}
|
||||
{t(`statuses.${status}`, status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' '))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -706,17 +710,17 @@ const TaskList = ({
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Priority
|
||||
{t('filters.priority')}
|
||||
</label>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
<option value="all">{t('filters.allPriorities')}</option>
|
||||
{priorities.map(priority => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority.charAt(0).toUpperCase() + priority.slice(1)}
|
||||
{t(`priorities.${priority}`, priority.charAt(0).toUpperCase() + priority.slice(1))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -725,7 +729,7 @@ const TaskList = ({
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Sort By
|
||||
{t('filters.sortBy')}
|
||||
</label>
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
@@ -736,14 +740,14 @@ const TaskList = ({
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="id-asc">ID (Ascending)</option>
|
||||
<option value="id-desc">ID (Descending)</option>
|
||||
<option value="title-asc">Title (A-Z)</option>
|
||||
<option value="title-desc">Title (Z-A)</option>
|
||||
<option value="status-asc">Status (Pending First)</option>
|
||||
<option value="status-desc">Status (Done First)</option>
|
||||
<option value="priority-asc">Priority (High First)</option>
|
||||
<option value="priority-desc">Priority (Low First)</option>
|
||||
<option value="id-asc">{t('sort.idAsc')}</option>
|
||||
<option value="id-desc">{t('sort.idDesc')}</option>
|
||||
<option value="title-asc">{t('sort.titleAsc')}</option>
|
||||
<option value="title-desc">{t('sort.titleDesc')}</option>
|
||||
<option value="status-asc">{t('sort.statusAsc')}</option>
|
||||
<option value="status-desc">{t('sort.statusDesc')}</option>
|
||||
<option value="priority-asc">{t('sort.priorityAsc')}</option>
|
||||
<option value="priority-desc">{t('sort.priorityDesc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,13 +755,13 @@ const TaskList = ({
|
||||
{/* Filter Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {filteredAndSortedTasks.length} of {tasks.length} tasks
|
||||
{t('filters.showing', { filtered: filteredAndSortedTasks.length, total: tasks.length })}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
Clear Filters
|
||||
{t('filters.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -769,34 +773,34 @@ const TaskList = ({
|
||||
onClick={() => handleSortChange('id')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
sortBy === 'id'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
sortBy === 'id'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
ID {getSortIcon('id')}
|
||||
{t('sort.id')} {getSortIcon('id')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSortChange('status')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
sortBy === 'status'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
sortBy === 'status'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
Status {getSortIcon('status')}
|
||||
{t('sort.status')} {getSortIcon('status')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSortChange('priority')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
sortBy === 'priority'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
sortBy === 'priority'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
Priority {getSortIcon('priority')}
|
||||
{t('sort.priority')} {getSortIcon('priority')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -805,8 +809,8 @@ const TaskList = ({
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium mb-2">No tasks match your filters</h3>
|
||||
<p className="text-sm">Try adjusting your search or filter criteria.</p>
|
||||
<h3 className="text-lg font-medium mb-2">{t('noMatchingTasks.title')}</h3>
|
||||
<p className="text-sm">{t('noMatchingTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'kanban' ? (
|
||||
@@ -844,13 +848,13 @@ const TaskList = ({
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
No tasks yet
|
||||
{t('kanban.noTasksYet')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{column.status === 'pending' ? 'Tasks will appear here' :
|
||||
column.status === 'in-progress' ? 'Move tasks here when started' :
|
||||
column.status === 'done' ? 'Completed tasks appear here' :
|
||||
'Tasks with this status will appear here'}
|
||||
{column.status === 'pending' ? t('kanban.tasksWillAppear') :
|
||||
column.status === 'in-progress' ? t('kanban.moveTasksHere') :
|
||||
column.status === 'done' ? t('kanban.completedTasksHere') :
|
||||
t('kanban.statusTasksHere')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -911,8 +915,8 @@ const TaskList = ({
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Getting Started with TaskMaster</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Your guide to productive task management</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('helpGuide.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('helpGuide.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -930,8 +934,8 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/50 dark:to-indigo-950/50 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">1</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Create a Product Requirements Document (PRD)</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Discuss your project idea and create a PRD that describes what you want to build.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.createPRD.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.createPRD.description')}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowPRDEditor?.();
|
||||
@@ -940,7 +944,7 @@ const TaskList = ({
|
||||
className="inline-flex items-center gap-2 text-sm bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-3 py-1.5 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Add PRD
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,12 +953,11 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/50 dark:to-emerald-950/50 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-green-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">2</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Generate Tasks from PRD</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Once you have a PRD, ask your AI assistant to parse it and TaskMaster will automatically break it down into manageable tasks with implementation details.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.generateTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.generateTasks.description')}</p>
|
||||
<div className="bg-white dark:bg-gray-800/50 rounded border border-green-200 dark:border-green-700/50 p-3 mb-2">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/prd.txt. Can you help me parse it and set up the initial tasks?"
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono whitespace-pre-wrap">
|
||||
{t('helpGuide.examples.parsePRD')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -964,12 +967,11 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-950/50 dark:to-orange-950/50 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-amber-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">3</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Analyze & Expand Tasks</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Ask your AI assistant to analyze task complexity and expand them into detailed subtasks for easier implementation.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.analyzeTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.analyzeTasks.description')}</p>
|
||||
<div className="bg-white dark:bg-gray-800/50 rounded border border-amber-200 dark:border-amber-700/50 p-3 mb-2">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"Task 5 seems complex. Can you break it down into subtasks?"
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono whitespace-pre-wrap">
|
||||
{t('helpGuide.examples.expandTask')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -979,12 +981,11 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/50 dark:to-pink-950/50 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">4</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Start Building</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Ask your AI assistant to begin working on tasks, update their status, and add new tasks as your project evolves.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.startBuilding.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.startBuilding.description')}</p>
|
||||
<div className="bg-white dark:bg-gray-800/50 rounded border border-purple-200 dark:border-purple-700/50 p-3 mb-3">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach."
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono whitespace-pre-wrap">
|
||||
{t('helpGuide.examples.addTask')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
@@ -993,50 +994,50 @@ const TaskList = ({
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
View more examples and usage patterns →
|
||||
{t('helpGuide.moreExamples')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro Tips */}
|
||||
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">💡 Pro Tips</h4>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('helpGuide.proTips.title')}</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Use the search bar to quickly find specific tasks
|
||||
{t('helpGuide.proTips.search')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Switch between Kanban, List, and Grid views using the view toggles
|
||||
{t('helpGuide.proTips.views')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Use filters to focus on specific task statuses or priorities
|
||||
{t('helpGuide.proTips.filters')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Click on any task to view detailed information and manage subtasks
|
||||
{t('helpGuide.proTips.details')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Learn More Section */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-950/50 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-3">📚 Learn More</h4>
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-3">{t('helpGuide.learnMore.title')}</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
TaskMaster AI is an advanced task management system built for developers. Get documentation, examples, and contribute to the project.
|
||||
{t('helpGuide.learnMore.description')}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
View on GitHub
|
||||
{t('helpGuide.learnMore.githubButton')}
|
||||
<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>
|
||||
@@ -1051,4 +1052,4 @@ const TaskList = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
export default TaskList;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { api } from '../utils/api';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const TaskMasterSetupWizard = ({
|
||||
isOpen = true,
|
||||
@@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({
|
||||
}
|
||||
}
|
||||
}`;
|
||||
navigator.clipboard.writeText(mcpConfig);
|
||||
copyTextToClipboard(mcpConfig);
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
|
||||
3
src/components/TasksSettings.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
|
||||
|
||||
export default TasksSettingsTab;
|
||||