Compare commits
218 Commits
v1.6.0
...
72c4b0749e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c4b0749e | ||
|
|
97ebef016a | ||
|
|
005033136b | ||
|
|
8fb43d358c | ||
|
|
4c40a33255 | ||
|
|
4086fdaa4e | ||
|
|
124c1ac600 | ||
|
|
9efe433d99 | ||
|
|
189a1b174c | ||
|
|
04a0ff311e | ||
|
|
efae890e34 | ||
|
|
ea33810a4f | ||
|
|
4fe6cc4272 | ||
|
|
ba70ad8e81 | ||
|
|
b066ec4c01 | ||
|
|
104e4260a7 | ||
|
|
8af982e706 | ||
|
|
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 | ||
|
|
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 | ||
|
|
9eb0be0f2b | ||
|
|
63eda35331 | ||
|
|
9c6d4a767e | ||
|
|
44c88ec15f | ||
|
|
6dd303a321 | ||
|
|
a100648ccb | ||
|
|
7d0fd141ff | ||
|
|
e5a05d9865 | ||
|
|
2d6c3b5755 | ||
|
|
2a5d27ffc0 | ||
|
|
3c9a4cab82 | ||
|
|
ce9ab0cd16 | ||
|
|
66fad9a6a2 | ||
|
|
0fcf906ff0 | ||
|
|
7e1f2940d3 | ||
|
|
533d589132 | ||
|
|
d1f310161f | ||
|
|
b853e8cda1 | ||
|
|
1610de1f22 | ||
|
|
3f743d8210 | ||
|
|
cbb18fb010 | ||
|
|
d8d754274a | ||
|
|
eb12aef641 | ||
|
|
5e574dbdec | ||
|
|
af9e9eec02 | ||
|
|
8c3ee770c3 | ||
|
|
58108c083c | ||
|
|
af0ad6b4b6 | ||
|
|
36d9f47e29 | ||
|
|
1f25f1e79b | ||
|
|
d7ed1de1cb | ||
|
|
9be54233d0 | ||
|
|
c8bcad71e7 | ||
|
|
a3f504aed2 | ||
|
|
f766ac1517 | ||
|
|
680d8f6fb1 | ||
|
|
1820f3bfa8 | ||
|
|
376e055443 | ||
|
|
f4becdc218 | ||
|
|
70b421e5a8 | ||
|
|
d74a99ef24 | ||
|
|
771f7e4d7b | ||
|
|
cab13a1534 | ||
|
|
1eba594418 | ||
|
|
ed5374a1bd | ||
|
|
573a04d2e5 | ||
|
|
133af82935 | ||
|
|
79981693f3 | ||
|
|
fb1117a999 | ||
|
|
40b8737732 | ||
|
|
15b95c4d08 | ||
|
|
3ff1db0331 | ||
|
|
dab0068d8f | ||
|
|
3daf21c3d1 | ||
|
|
34583a7c7b | ||
|
|
b3498932e1 | ||
|
|
f52ca8e702 | ||
|
|
11b2ff588a | ||
|
|
06bb5feb09 | ||
|
|
4ca78ba69a | ||
|
|
975e4b04a6 | ||
|
|
e5709d71e0 | ||
|
|
4401498f3f | ||
|
|
75e8161213 | ||
|
|
d82a004224 | ||
|
|
c1e7bb6c10 | ||
|
|
b2fef1c75f | ||
|
|
52c8a813c7 | ||
|
|
af6248ca7f | ||
|
|
a2eb2c4bea | ||
|
|
13f06ed241 | ||
|
|
59e4c11a67 | ||
|
|
2e75676b58 | ||
|
|
9e98bc739f | ||
|
|
24815d6894 | ||
|
|
2603b8aaf1 | ||
|
|
e15a78ed62 | ||
|
|
db7ce4dd74 | ||
|
|
50f6cdfac9 | ||
|
|
cdce59edb4 | ||
|
|
0f45472402 | ||
|
|
28e27ed2fb | ||
|
|
3e7e60a3a8 | ||
|
|
cd6e5befb8 | ||
|
|
003e8f4be3 | ||
|
|
0a39079c5c | ||
|
|
4e5aa50505 | ||
|
|
cf6f0e7321 | ||
|
|
5dd1fcfb4d | ||
|
|
ece52adac2 | ||
|
|
6c55638397 | ||
|
|
e28d989bee |
31
.env.example
@@ -1,5 +1,15 @@
|
||||
# Claude Code UI Environment Configuration
|
||||
# Only includes variables that are actually used in the code
|
||||
#
|
||||
# TIP: Run 'cloudcli status' to see where this file should be located
|
||||
# and to view your current configuration.
|
||||
#
|
||||
# Available CLI commands:
|
||||
# claude-code-ui - Start the server (default)
|
||||
# cloudcli start - Start the server
|
||||
# cloudcli status - Show configuration and data locations
|
||||
# cloudcli help - Show help information
|
||||
# cloudcli version - Show version information
|
||||
|
||||
# =============================================================================
|
||||
# SERVER CONFIGURATION
|
||||
@@ -9,4 +19,23 @@
|
||||
#API server
|
||||
PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=5173
|
||||
VITE_PORT=5173
|
||||
|
||||
# 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)
|
||||
VITE_CONTEXT_WINDOW=160000
|
||||
CONTEXT_WINDOW=160000
|
||||
|
||||
# VITE_IS_PLATFORM=false
|
||||
|
||||
29
.gitignore
vendored
@@ -98,10 +98,35 @@ temp/
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
# Claude specific
|
||||
# AI specific
|
||||
.claude/
|
||||
.cursor/
|
||||
.roo/
|
||||
.taskmaster/
|
||||
.cline/
|
||||
.windsurf/
|
||||
.serena/
|
||||
CLAUDE.md
|
||||
.mcp.json
|
||||
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.sqlite3
|
||||
|
||||
logs
|
||||
dev-debug.log
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# OS specific
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/
|
||||
|
||||
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
|
||||
21
.release-it.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"git": {
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "Claude Code UI v${version}",
|
||||
"releaseNotes": {
|
||||
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||
"excludeMatches": ["viper151"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": ["npm run build"]
|
||||
}
|
||||
}
|
||||
161
README.md
@@ -1,10 +1,10 @@
|
||||
<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), Anthropic's official CLI for AI-assisted coding. You can use it locally or remotely to view your active projects and sessions in claude code and make changes to them the same way you would do it in claude code CLI. This gives you a proper interface that works everywhere.
|
||||
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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -25,6 +25,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
<em>Responsive mobile design with touch navigation</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI Selection</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Select between Claude Code, Cursor CLI and Codex</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -33,12 +41,14 @@ 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
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code 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.5, Opus 4.5, and GPT-5.2
|
||||
|
||||
|
||||
## Quick Start
|
||||
@@ -46,9 +56,103 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||
- [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, and/or
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured
|
||||
|
||||
### Installation
|
||||
### One-click Operation (Recommended)
|
||||
|
||||
No installation required, direct operation:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
|
||||
|
||||
**To restart**: Simply run the same `npx` command again after stopping the server
|
||||
### 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
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
@@ -99,19 +203,33 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
|
||||
|
||||
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
|
||||
|
||||
## TaskMaster AI Integration *(Optional)*
|
||||
|
||||
Claude Code UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
|
||||
|
||||
It provides
|
||||
- AI-powered task generation from PRDs (Product Requirements Documents)
|
||||
- Smart task breakdown and dependency management
|
||||
- Visual task boards and progress tracking
|
||||
|
||||
**Setup & Documentation**: Visit the [TaskMaster AI GitHub repository](https://github.com/eyaltoledano/claude-task-master) for installation instructions, configuration guides, and usage examples.
|
||||
After installing it you should be able to enable it from the Settings
|
||||
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### 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 CLI** - You can either use the adapted chat interface or use the shell button to connect to Claude Code 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
|
||||
@@ -125,6 +243,11 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
#### Git Explorer
|
||||
|
||||
|
||||
#### TaskMaster AI Integration *(Optional)*
|
||||
- **Visual Task Board** - Kanban-style interface for managing development tasks
|
||||
- **PRD Parser** - Create Product Requirements Documents and parse them into structured tasks
|
||||
- **Progress Tracking** - Real-time status updates and completion tracking
|
||||
|
||||
#### Session Management
|
||||
- **Session Persistence** - All conversations automatically saved
|
||||
- **Session Organization** - Group sessions by project and timestamp
|
||||
@@ -144,16 +267,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
|
||||
- **Claude CLI Integration** - 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)
|
||||
@@ -196,13 +319,13 @@ We welcome contributions! Please follow these guidelines:
|
||||
|
||||
### 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
|
||||
@@ -223,11 +346,13 @@ 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
|
||||
- **[CodeMirror](https://codemirror.net/)** - Advanced code editor
|
||||
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning
|
||||
|
||||
## Support & Community
|
||||
|
||||
@@ -241,5 +366,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>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<meta charset="UTF-8" />
|
||||
<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, user-scalable=no, viewport-fit=cover" />
|
||||
<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>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- iOS Safari PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<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" />
|
||||
|
||||
<!-- iOS Safari Icons -->
|
||||
|
||||
4810
package-lock.json
generated
57
package.json
@@ -1,38 +1,66 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.6.0",
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.13.6",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
"bin": {
|
||||
"claude-code-ui": "server/cli.js",
|
||||
"cloudcli": "server/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"server/",
|
||||
"shared/",
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/siteboon/claudecodeui/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||
"server": "node server/index.js",
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "npm run build && npm run server"
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "./release.sh"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude coode",
|
||||
"ai",
|
||||
"code",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"assistant"
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
"@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.75.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",
|
||||
@@ -41,7 +69,10 @@
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
@@ -52,20 +83,26 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.14.2",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"auto-changelog": "^2.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"node-gyp": "^10.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"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>
|
||||
85
public/clear-cache.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Clear Cache - Claude Code UI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
#status {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Clear Cache & Service Worker</h1>
|
||||
<p>If you're seeing a blank page or old content, click the button below to clear all cached data.</p>
|
||||
|
||||
<button onclick="clearEverything()">Clear Cache & Reload</button>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<script>
|
||||
async function clearEverything() {
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = '<p>Clearing cache and service workers...</p>';
|
||||
|
||||
try {
|
||||
// Unregister all service workers
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (let registration of registrations) {
|
||||
await registration.unregister();
|
||||
status.innerHTML += '<p class="success">✓ Unregistered service worker</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
for (let cacheName of cacheNames) {
|
||||
await caches.delete(cacheName);
|
||||
status.innerHTML += `<p class="success">✓ Deleted cache: ${cacheName}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
status.innerHTML += '<p class="success">✓ Cleared localStorage</p>';
|
||||
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
status.innerHTML += '<p class="success">✓ Cleared sessionStorage</p>';
|
||||
|
||||
status.innerHTML += '<p class="success"><strong>✓ All caches cleared!</strong></p>';
|
||||
status.innerHTML += '<p>Cache cleared successfully. You can now close this tab or <a href="/">go to home page</a>.</p>';
|
||||
|
||||
} catch (error) {
|
||||
status.innerHTML += `<p class="error">✗ Error: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
</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
public/icons/cursor.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z" fill="url(#lobe-icons-cursorundefined-fill-0)"></path><path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="url(#lobe-icons-cursorundefined-fill-1)"></path><path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="url(#lobe-icons-cursorundefined-fill-2)"></path><path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path><path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-0" x1="11.925" x2="11.925" y1="12" y2="24"><stop offset=".16" stop-color="#000" stop-opacity=".39"></stop><stop offset=".658" stop-color="#000" stop-opacity=".8"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-1" x1="22.35" x2="11.925" y1="6.037" y2="12.15"><stop offset=".182" stop-color="#000" stop-opacity=".31"></stop><stop offset=".715" stop-color="#000" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-2" x1="11.925" x2="1.5" y1="0" y2="18"><stop stop-color="#000" stop-opacity=".6"></stop><stop offset=".667" stop-color="#000" stop-opacity=".22"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
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 |
BIN
public/screenshots/cli-selection.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
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 "$@"
|
||||
@@ -1,391 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
// Build Claude CLI command - start with print/resume flags first
|
||||
const args = [];
|
||||
|
||||
// Add print flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
|
||||
// Separate arguments for better cross-platform compatibility
|
||||
// This prevents issues with spaces and quotes on Windows
|
||||
args.push('--print');
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
const workingDir = cwd || process.cwd();
|
||||
|
||||
// Handle images by saving them to temporary files and passing paths to Claude
|
||||
const tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
if (images && images.length > 0) {
|
||||
try {
|
||||
// Create temp directory in the project directory so Claude can access it
|
||||
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Save each image to a temp file
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Extract base64 data and mime type
|
||||
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
console.error('Invalid image data format');
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = matches;
|
||||
const extension = mimeType.split('/')[1] || 'png';
|
||||
const filename = `image_${index}.${extension}`;
|
||||
const filepath = path.join(tempDir, filename);
|
||||
|
||||
// Write base64 data to file
|
||||
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
||||
tempImagePaths.push(filepath);
|
||||
}
|
||||
|
||||
// Include the full image paths in the prompt for Claude to reference
|
||||
// Only modify the command if we actually have images and a command
|
||||
if (tempImagePaths.length > 0 && command && command.trim()) {
|
||||
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
const modifiedCommand = command + imageNote;
|
||||
|
||||
// Update the command in args - now that --print and command are separate
|
||||
const printIndex = args.indexOf('--print');
|
||||
if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) {
|
||||
args[printIndex + 1] = modifiedCommand;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing images for Claude:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add resume flag if resuming
|
||||
if (resume && sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
}
|
||||
|
||||
// Add basic flags
|
||||
args.push('--output-format', 'stream-json', '--verbose');
|
||||
|
||||
// Add MCP config flag only if MCP servers are configured
|
||||
try {
|
||||
console.log('🔍 Starting MCP config check...');
|
||||
// Use already imported modules (fs.promises is imported as fs, path, os)
|
||||
const fsSync = await import('fs'); // Import synchronous fs methods
|
||||
console.log('✅ Successfully imported fs sync methods');
|
||||
|
||||
// Check for MCP config in ~/.claude.json
|
||||
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
|
||||
|
||||
console.log(`🔍 Checking for MCP configs in: ${claudeConfigPath}`);
|
||||
console.log(` Claude config exists: ${fsSync.existsSync(claudeConfigPath)}`);
|
||||
|
||||
let hasMcpServers = false;
|
||||
|
||||
// Check Claude config for MCP servers
|
||||
if (fsSync.existsSync(claudeConfigPath)) {
|
||||
try {
|
||||
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
|
||||
|
||||
// Check global MCP servers
|
||||
if (claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) {
|
||||
console.log(`✅ Found ${Object.keys(claudeConfig.mcpServers).length} global MCP servers`);
|
||||
hasMcpServers = true;
|
||||
}
|
||||
|
||||
// Check project-specific MCP servers
|
||||
if (!hasMcpServers && claudeConfig.claudeProjects) {
|
||||
const currentProjectPath = process.cwd();
|
||||
const projectConfig = claudeConfig.claudeProjects[currentProjectPath];
|
||||
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
|
||||
console.log(`✅ Found ${Object.keys(projectConfig.mcpServers).length} project MCP servers`);
|
||||
hasMcpServers = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ Failed to parse Claude config:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 hasMcpServers result: ${hasMcpServers}`);
|
||||
|
||||
if (hasMcpServers) {
|
||||
// Use Claude config file if it has MCP servers
|
||||
let configPath = null;
|
||||
|
||||
if (fsSync.existsSync(claudeConfigPath)) {
|
||||
try {
|
||||
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
|
||||
|
||||
// Check if we have any MCP servers (global or project-specific)
|
||||
const hasGlobalServers = claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0;
|
||||
const currentProjectPath = process.cwd();
|
||||
const projectConfig = claudeConfig.claudeProjects && claudeConfig.claudeProjects[currentProjectPath];
|
||||
const hasProjectServers = projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0;
|
||||
|
||||
if (hasGlobalServers || hasProjectServers) {
|
||||
configPath = claudeConfigPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// No valid config found
|
||||
}
|
||||
}
|
||||
|
||||
if (configPath) {
|
||||
console.log(`📡 Adding MCP config: ${configPath}`);
|
||||
args.push('--mcp-config', configPath);
|
||||
} else {
|
||||
console.log('⚠️ MCP servers detected but no valid config file found');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If there's any error checking for MCP configs, don't add the flag
|
||||
console.log('❌ MCP config check failed:', error.message);
|
||||
console.log('📍 Error stack:', error.stack);
|
||||
console.log('Note: MCP config check failed, proceeding without MCP support');
|
||||
}
|
||||
|
||||
// Add model for new sessions
|
||||
if (!resume) {
|
||||
args.push('--model', 'sonnet');
|
||||
}
|
||||
|
||||
// Add permission mode if specified (works for both new and resumed sessions)
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
console.log('🔒 Using permission mode:', permissionMode);
|
||||
}
|
||||
|
||||
// Add tools settings flags
|
||||
// Don't use --dangerously-skip-permissions when in plan mode
|
||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
|
||||
} else {
|
||||
// Only add allowed/disallowed tools if not skipping permissions
|
||||
|
||||
// Collect all allowed tools, including plan mode defaults
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// Add plan mode specific tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
|
||||
// Add plan mode tools that aren't already in the allowed list
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
}
|
||||
}
|
||||
console.log('📝 Plan mode: Added default allowed tools:', planModeTools);
|
||||
}
|
||||
|
||||
// Add allowed tools
|
||||
if (allowedTools.length > 0) {
|
||||
for (const tool of allowedTools) {
|
||||
args.push('--allowedTools', tool);
|
||||
console.log('✅ Allowing tool:', tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Add disallowed tools
|
||||
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
||||
for (const tool of settings.disallowedTools) {
|
||||
args.push('--disallowedTools', tool);
|
||||
console.log('❌ Disallowing tool:', tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Log when skip permissions is disabled due to plan mode
|
||||
if (settings.skipPermissions && permissionMode === 'plan') {
|
||||
console.log('📝 Skip permissions disabled due to plan mode');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
||||
}).join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
||||
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
||||
|
||||
const claudeProcess = spawnFunction('claude', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
// Attach temp file info to process for cleanup later
|
||||
claudeProcess.tempImagePaths = tempImagePaths;
|
||||
claudeProcess.tempDir = tempDir;
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||
activeClaudeProcesses.set(processKey, claudeProcess);
|
||||
|
||||
// Handle stdout (streaming JSON responses)
|
||||
claudeProcess.stdout.on('data', (data) => {
|
||||
const rawOutput = data.toString();
|
||||
console.log('📤 Claude CLI stdout:', rawOutput);
|
||||
|
||||
const lines = rawOutput.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
console.log('📄 Parsed JSON response:', response);
|
||||
|
||||
// Capture session ID if it's in the response
|
||||
if (response.session_id && !capturedSessionId) {
|
||||
capturedSessionId = response.session_id;
|
||||
console.log('📝 Captured session ID:', capturedSessionId);
|
||||
|
||||
// Update process key with captured session ID
|
||||
if (processKey !== capturedSessionId) {
|
||||
activeClaudeProcesses.delete(processKey);
|
||||
activeClaudeProcesses.set(capturedSessionId, claudeProcess);
|
||||
}
|
||||
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Send parsed response to WebSocket
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-response',
|
||||
data: response
|
||||
}));
|
||||
} catch (parseError) {
|
||||
console.log('📄 Non-JSON response:', line);
|
||||
// If not JSON, send as raw text
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-output',
|
||||
data: line
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
console.error('Claude CLI stderr:', data.toString());
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-error',
|
||||
error: data.toString()
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
claudeProcess.on('close', async (code) => {
|
||||
console.log(`Claude CLI process exited with code ${code}`);
|
||||
|
||||
// Clean up process reference
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeClaudeProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-complete',
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
}));
|
||||
|
||||
// Clean up temporary image files if any
|
||||
if (claudeProcess.tempImagePaths && claudeProcess.tempImagePaths.length > 0) {
|
||||
for (const imagePath of claudeProcess.tempImagePaths) {
|
||||
await fs.unlink(imagePath).catch(err =>
|
||||
console.error(`Failed to delete temp image ${imagePath}:`, err)
|
||||
);
|
||||
}
|
||||
if (claudeProcess.tempDir) {
|
||||
await fs.rm(claudeProcess.tempDir, { recursive: true, force: true }).catch(err =>
|
||||
console.error(`Failed to delete temp directory ${claudeProcess.tempDir}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Claude CLI exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
claudeProcess.on('error', (error) => {
|
||||
console.error('Claude CLI process error:', error);
|
||||
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeClaudeProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
}));
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Handle stdin for interactive mode
|
||||
if (command) {
|
||||
// For --print mode with arguments, we don't need to write to stdin
|
||||
claudeProcess.stdin.end();
|
||||
} else {
|
||||
// For interactive mode, we need to write the command to stdin if provided later
|
||||
// Keep stdin open for interactive session
|
||||
if (command !== undefined) {
|
||||
claudeProcess.stdin.write(command + '\n');
|
||||
claudeProcess.stdin.end();
|
||||
}
|
||||
// If no command provided, stdin stays open for interactive use
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function abortClaudeSession(sessionId) {
|
||||
const process = activeClaudeProcesses.get(sessionId);
|
||||
if (process) {
|
||||
console.log(`🛑 Aborting Claude session: ${sessionId}`);
|
||||
process.kill('SIGTERM');
|
||||
activeClaudeProcesses.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export {
|
||||
spawnClaude,
|
||||
abortClaudeSession
|
||||
};
|
||||
530
server/claude-sdk.js
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Claude SDK Integration
|
||||
*
|
||||
* This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
|
||||
* It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
|
||||
* and maintainability.
|
||||
*
|
||||
* Key features:
|
||||
* - Direct SDK integration without child processes
|
||||
* - Session management with abort capability
|
||||
* - Options mapping between CLI and SDK formats
|
||||
* - WebSocket message streaming
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
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();
|
||||
|
||||
/**
|
||||
* Maps CLI options to SDK-compatible options format
|
||||
* @param {Object} options - CLI options
|
||||
* @returns {Object} SDK-compatible options
|
||||
*/
|
||||
function mapCliOptionsToSDK(options = {}) {
|
||||
const { sessionId, cwd, toolsSettings, permissionMode, images } = options;
|
||||
|
||||
const sdkOptions = {};
|
||||
|
||||
// Map working directory
|
||||
if (cwd) {
|
||||
sdkOptions.cwd = cwd;
|
||||
}
|
||||
|
||||
// Map permission mode
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
sdkOptions.permissionMode = permissionMode;
|
||||
}
|
||||
|
||||
// Map tool settings
|
||||
const settings = toolsSettings || {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
// Handle tool permissions
|
||||
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', '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;
|
||||
}
|
||||
}
|
||||
|
||||
// Map model (default to 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) {
|
||||
sdkOptions.resume = sessionId;
|
||||
}
|
||||
|
||||
return sdkOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a session to the active sessions map
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @param {Object} queryInstance - SDK query instance
|
||||
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
||||
* @param {string} tempDir - Temp directory for cleanup
|
||||
*/
|
||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
|
||||
activeSessions.set(sessionId, {
|
||||
instance: queryInstance,
|
||||
startTime: Date.now(),
|
||||
status: 'active',
|
||||
tempImagePaths,
|
||||
tempDir
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a session from the active sessions map
|
||||
* @param {string} sessionId - Session identifier
|
||||
*/
|
||||
function removeSession(sessionId) {
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a session from the active sessions map
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Object|undefined} Session data or undefined
|
||||
*/
|
||||
function getSession(sessionId) {
|
||||
return activeSessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all active session IDs
|
||||
* @returns {Array<string>} Array of active session IDs
|
||||
*/
|
||||
function getAllSessions() {
|
||||
return Array.from(activeSessions.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms SDK messages to WebSocket format expected by frontend
|
||||
* @param {Object} sdkMessage - SDK message object
|
||||
* @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
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts token usage from SDK result messages
|
||||
* @param {Object} resultMessage - SDK result message
|
||||
* @returns {Object|null} Token budget object or null
|
||||
*/
|
||||
function extractTokenBudget(resultMessage) {
|
||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first model's usage data
|
||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
||||
const modelData = resultMessage.modelUsage[modelKey];
|
||||
|
||||
if (!modelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use cumulative tokens if available (tracks total for the session)
|
||||
// Otherwise fall back to per-request tokens
|
||||
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
||||
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
|
||||
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
|
||||
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
|
||||
|
||||
// Total used = input + output + cache tokens
|
||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
// Use configured context window budget from environment (default 160000)
|
||||
// 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}`);
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
total: contextWindow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles image processing for SDK queries
|
||||
* Saves base64 images to temporary files and returns modified prompt with file paths
|
||||
* @param {string} command - Original user prompt
|
||||
* @param {Array} images - Array of image objects with base64 data
|
||||
* @param {string} cwd - Working directory for temp file creation
|
||||
* @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
|
||||
*/
|
||||
async function handleImages(command, images, cwd) {
|
||||
const tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return { modifiedCommand: command, tempImagePaths, tempDir };
|
||||
}
|
||||
|
||||
try {
|
||||
// Create temp directory in the project directory
|
||||
const workingDir = cwd || process.cwd();
|
||||
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Save each image to a temp file
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Extract base64 data and mime type
|
||||
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
console.error('Invalid image data format');
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = matches;
|
||||
const extension = mimeType.split('/')[1] || 'png';
|
||||
const filename = `image_${index}.${extension}`;
|
||||
const filepath = path.join(tempDir, filename);
|
||||
|
||||
// Write base64 data to file
|
||||
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
||||
tempImagePaths.push(filepath);
|
||||
}
|
||||
|
||||
// Include the full image paths in the prompt
|
||||
let modifiedCommand = command;
|
||||
if (tempImagePaths.length > 0 && command && command.trim()) {
|
||||
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
modifiedCommand = command + imageNote;
|
||||
}
|
||||
|
||||
console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
||||
return { modifiedCommand, tempImagePaths, tempDir };
|
||||
} catch (error) {
|
||||
console.error('Error processing images for SDK:', error);
|
||||
return { modifiedCommand: command, tempImagePaths, tempDir };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up temporary image files
|
||||
* @param {Array<string>} tempImagePaths - Array of temp file paths to delete
|
||||
* @param {string} tempDir - Temp directory to remove
|
||||
*/
|
||||
async function cleanupTempFiles(tempImagePaths, tempDir) {
|
||||
if (!tempImagePaths || tempImagePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete individual temp files
|
||||
for (const imagePath of tempImagePaths) {
|
||||
await fs.unlink(imagePath).catch(err =>
|
||||
console.error(`Failed to delete temp image ${imagePath}:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete temp directory
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
|
||||
console.error(`Failed to delete temp directory ${tempDir}:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Cleaned up ${tempImagePaths.length} temp image files`);
|
||||
} catch (error) {
|
||||
console.error('Error during temp file cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads MCP server configurations from ~/.claude.json
|
||||
* @param {string} cwd - Current working directory for project-specific configs
|
||||
* @returns {Object|null} MCP servers object or null if none found
|
||||
*/
|
||||
async function loadMcpConfig(cwd) {
|
||||
try {
|
||||
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
|
||||
|
||||
// Check if config file exists
|
||||
try {
|
||||
await fs.access(claudeConfigPath);
|
||||
} catch (error) {
|
||||
// File doesn't exist, return null
|
||||
console.log('No ~/.claude.json found, proceeding without MCP servers');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read and parse config file
|
||||
let claudeConfig;
|
||||
try {
|
||||
const configContent = await fs.readFile(claudeConfigPath, 'utf8');
|
||||
claudeConfig = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ~/.claude.json:', error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract MCP servers (merge global and project-specific)
|
||||
let mcpServers = {};
|
||||
|
||||
// Add global MCP servers
|
||||
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
||||
mcpServers = { ...claudeConfig.mcpServers };
|
||||
console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
||||
}
|
||||
|
||||
// Add/override with project-specific MCP servers
|
||||
if (claudeConfig.claudeProjects && 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`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if no servers found
|
||||
if (Object.keys(mcpServers).length === 0) {
|
||||
console.log('No MCP servers configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
||||
return mcpServers;
|
||||
} catch (error) {
|
||||
console.error('Error loading MCP config:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Claude query using the SDK
|
||||
* @param {string} command - User prompt/command
|
||||
* @param {Object} options - Query options
|
||||
* @param {Object} ws - WebSocket connection
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const { sessionId } = options;
|
||||
let capturedSessionId = sessionId;
|
||||
let sessionCreatedSent = false;
|
||||
let tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
try {
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK(options);
|
||||
|
||||
// Load MCP configuration
|
||||
const mcpServers = await loadMcpConfig(options.cwd);
|
||||
if (mcpServers) {
|
||||
sdkOptions.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
// Handle images - save to temp files and modify prompt
|
||||
const imageResult = await handleImages(command, options.images, options.cwd);
|
||||
const finalCommand = imageResult.modifiedCommand;
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
// Create SDK query instance
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
|
||||
// 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');
|
||||
for await (const message of queryInstance) {
|
||||
// Capture session ID from first message
|
||||
if (message.session_id && !capturedSessionId) {
|
||||
|
||||
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({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
});
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
// Transform and send message to WebSocket
|
||||
const transformedMessage = transformMessage(message);
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: transformedMessage
|
||||
});
|
||||
|
||||
// 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({
|
||||
type: 'token-budget',
|
||||
data: tokenBudget
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up session on completion
|
||||
if (capturedSessionId) {
|
||||
removeSession(capturedSessionId);
|
||||
}
|
||||
|
||||
// Clean up temporary image files
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send completion event
|
||||
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');
|
||||
|
||||
} catch (error) {
|
||||
console.error('SDK query error:', error);
|
||||
|
||||
// Clean up session on error
|
||||
if (capturedSessionId) {
|
||||
removeSession(capturedSessionId);
|
||||
}
|
||||
|
||||
// Clean up temporary image files on error
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send error to WebSocket
|
||||
ws.send({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts an active SDK session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {boolean} True if session was aborted, false if not found
|
||||
*/
|
||||
async function abortClaudeSDKSession(sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
console.log(`Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Aborting SDK session: ${sessionId}`);
|
||||
|
||||
// Call interrupt() on the query instance
|
||||
await session.instance.interrupt();
|
||||
|
||||
// Update session status
|
||||
session.status = 'aborted';
|
||||
|
||||
// Clean up temporary image files
|
||||
await cleanupTempFiles(session.tempImagePaths, session.tempDir);
|
||||
|
||||
// Clean up session
|
||||
removeSession(sessionId);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error aborting session ${sessionId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an SDK session is currently active
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {boolean} True if session is active
|
||||
*/
|
||||
function isClaudeSDKSessionActive(sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
return session && session.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all active SDK session IDs
|
||||
* @returns {Array<string>} Array of active session IDs
|
||||
*/
|
||||
function getActiveClaudeSDKSessions() {
|
||||
return getAllSessions();
|
||||
}
|
||||
|
||||
// Export public API
|
||||
export {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions
|
||||
};
|
||||
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);
|
||||
});
|
||||
267
server/cursor-cli.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnCursor(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let messageBuffer = ''; // Buffer for accumulating assistant messages
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
allowedShellCommands: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
// Build Cursor CLI command
|
||||
const args = [];
|
||||
|
||||
// Build flags allowing both resume and prompt together (reply in existing session)
|
||||
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
||||
if (sessionId) {
|
||||
args.push('--resume=' + sessionId);
|
||||
}
|
||||
|
||||
if (command && command.trim()) {
|
||||
// Provide a prompt (works for both new and resumed sessions)
|
||||
args.push('-p', command);
|
||||
|
||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||
if (!sessionId && model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
// Request streaming JSON when we are providing a prompt
|
||||
args.push('--output-format', 'stream-json');
|
||||
}
|
||||
|
||||
// Add skip permissions flag if enabled
|
||||
if (skipPermissions || settings.skipPermissions) {
|
||||
args.push('-f');
|
||||
console.log('⚠️ Using -f flag (skip permissions)');
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
|
||||
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
|
||||
const cursorProcess = spawnFunction('cursor-agent', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || Date.now().toString();
|
||||
activeCursorProcesses.set(processKey, cursorProcess);
|
||||
|
||||
// Handle stdout (streaming JSON responses)
|
||||
cursorProcess.stdout.on('data', (data) => {
|
||||
const rawOutput = data.toString();
|
||||
console.log('📤 Cursor CLI stdout:', rawOutput);
|
||||
|
||||
const lines = rawOutput.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
console.log('📄 Parsed JSON response:', response);
|
||||
|
||||
// Handle different message types
|
||||
switch (response.type) {
|
||||
case 'system':
|
||||
if (response.subtype === 'init') {
|
||||
// Capture session ID
|
||||
if (response.session_id && !capturedSessionId) {
|
||||
capturedSessionId = response.session_id;
|
||||
console.log('📝 Captured session ID:', capturedSessionId);
|
||||
|
||||
// Update process key with captured session ID
|
||||
if (processKey !== capturedSessionId) {
|
||||
activeCursorProcesses.delete(processKey);
|
||||
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({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId,
|
||||
model: response.model,
|
||||
cwd: response.cwd
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send system info to frontend
|
||||
ws.send({
|
||||
type: 'cursor-system',
|
||||
data: response
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// Forward user message
|
||||
ws.send({
|
||||
type: 'cursor-user',
|
||||
data: response
|
||||
});
|
||||
break;
|
||||
|
||||
case 'assistant':
|
||||
// Accumulate assistant message chunks
|
||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||
const textContent = response.message.content[0].text;
|
||||
messageBuffer += textContent;
|
||||
|
||||
// Send as Claude-compatible format for frontend
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
delta: {
|
||||
type: 'text_delta',
|
||||
text: textContent
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
// Session complete
|
||||
console.log('Cursor session result:', response);
|
||||
|
||||
// Send final message if we have buffered content
|
||||
if (messageBuffer) {
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_stop'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
ws.send({
|
||||
type: 'cursor-result',
|
||||
sessionId: capturedSessionId || sessionId,
|
||||
data: response,
|
||||
success: response.subtype === 'success'
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Forward any other message types
|
||||
ws.send({
|
||||
type: 'cursor-response',
|
||||
data: response
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log('📄 Non-JSON response:', line);
|
||||
// If not JSON, send as raw text
|
||||
ws.send({
|
||||
type: 'cursor-output',
|
||||
data: line
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
cursorProcess.stderr.on('data', (data) => {
|
||||
console.error('Cursor CLI stderr:', data.toString());
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: data.toString()
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
cursorProcess.on('close', async (code) => {
|
||||
console.log(`Cursor CLI process exited with code ${code}`);
|
||||
|
||||
// Clean up process reference
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: finalSessionId,
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Cursor CLI exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
cursorProcess.on('error', (error) => {
|
||||
console.error('Cursor CLI process error:', error);
|
||||
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Close stdin since Cursor doesn't need interactive input
|
||||
cursorProcess.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
function abortCursorSession(sessionId) {
|
||||
const process = activeCursorProcesses.get(sessionId);
|
||||
if (process) {
|
||||
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
|
||||
process.kill('SIGTERM');
|
||||
activeCursorProcesses.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCursorSessionActive(sessionId) {
|
||||
return activeCursorProcesses.has(sessionId);
|
||||
}
|
||||
|
||||
function getActiveCursorSessions() {
|
||||
return Array.from(activeCursorProcesses.keys());
|
||||
}
|
||||
|
||||
export {
|
||||
spawnCursor,
|
||||
abortCursorSession,
|
||||
isCursorSessionActive,
|
||||
getActiveCursorSessions
|
||||
};
|
||||
@@ -1,18 +1,86 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +88,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;
|
||||
@@ -76,11 +145,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);
|
||||
935
server/index.js
@@ -20,6 +20,22 @@ const validateApiKey = (req, res, next) => {
|
||||
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
// Platform mode: use single database user
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
return res.status(500).json({ error: 'Platform mode: No user found in database' });
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Platform mode error:', error);
|
||||
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Normal OSS JWT validation
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
@@ -29,13 +45,13 @@ const authenticateToken = async (req, res, next) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
|
||||
// Verify user still exists and is active
|
||||
const user = userDb.getUserById(decoded.userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||
}
|
||||
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -58,10 +74,25 @@ const generateToken = (user) => {
|
||||
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
// Platform mode: bypass token validation, return first user
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
return { userId: user.id, username: user.username };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Platform mode WebSocket error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal OSS JWT validation
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
|
||||
389
server/openai-codex.js
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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',
|
||||
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);
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
sendMessage(ws, {
|
||||
type: 'codex-complete',
|
||||
sessionId: currentSessionId,
|
||||
actualSessionId: thread.id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
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 = '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';
|
||||
|
||||
// The SDK doesn't have a direct abort method, but marking status
|
||||
// will cause the streaming loop to exit
|
||||
|
||||
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
|
||||
1171
server/projects.js
1230
server/routes/agent.js
Normal file
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;
|
||||
345
server/routes/codex.js
Normal file
@@ -0,0 +1,345 @@
|
||||
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: false, message: 'No Codex configuration file found', 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;
|
||||
521
server/routes/commands.js
Normal file
@@ -0,0 +1,521 @@
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
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);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Recursively scan directory for command files (.md)
|
||||
* @param {string} dir - Directory to scan
|
||||
* @param {string} baseDir - Base directory for relative paths
|
||||
* @param {string} namespace - Namespace for commands (e.g., 'project', 'user')
|
||||
* @returns {Promise<Array>} Array of command objects
|
||||
*/
|
||||
async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
const commands = [];
|
||||
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fs.access(dir);
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
|
||||
commands.push(...subCommands);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Parse markdown file for metadata
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
const { data: frontmatter, content: commandContent } = matter(content);
|
||||
|
||||
// Calculate relative path from baseDir for command name
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
// Remove .md extension and convert to command name
|
||||
const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
|
||||
|
||||
// Extract description from frontmatter or first line of content
|
||||
let description = frontmatter.description || '';
|
||||
if (!description) {
|
||||
const firstLine = commandContent.trim().split('\n')[0];
|
||||
description = firstLine.replace(/^#+\s*/, '').trim();
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: fullPath,
|
||||
relativePath,
|
||||
description,
|
||||
namespace,
|
||||
metadata: frontmatter
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error parsing command file ${fullPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Directory doesn't exist or can't be accessed - this is okay
|
||||
if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
|
||||
console.error(`Error scanning directory ${dir}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in commands that are always available
|
||||
*/
|
||||
const builtInCommands = [
|
||||
{
|
||||
name: '/help',
|
||||
description: 'Show help documentation for Claude Code',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/clear',
|
||||
description: 'Clear the conversation history',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/model',
|
||||
description: 'Switch or view the current AI model',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/cost',
|
||||
description: 'Display token usage and cost information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/memory',
|
||||
description: 'Open CLAUDE.md memory file for editing',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/config',
|
||||
description: 'Open settings and configuration',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/status',
|
||||
description: 'Show system status and version information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/rewind',
|
||||
description: 'Rewind the conversation to a previous state',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Built-in command handlers
|
||||
* Each handler returns { type: 'builtin', action: string, data: any }
|
||||
*/
|
||||
const builtInHandlers = {
|
||||
'/help': async (args, context) => {
|
||||
const helpText = `# Claude Code Commands
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
${builtInCommands.map(cmd => `### ${cmd.name}
|
||||
${cmd.description}
|
||||
`).join('\n')}
|
||||
|
||||
## Custom Commands
|
||||
|
||||
Custom commands can be created in:
|
||||
- Project: \`.claude/commands/\` (project-specific)
|
||||
- User: \`~/.claude/commands/\` (available in all projects)
|
||||
|
||||
### Command Syntax
|
||||
|
||||
- **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional
|
||||
- **File Includes**: Use \`@filename\` to include file contents
|
||||
- **Bash Commands**: Use \`!command\` to execute bash commands
|
||||
|
||||
### Examples
|
||||
|
||||
\`\`\`markdown
|
||||
/mycommand arg1 arg2
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'help',
|
||||
data: {
|
||||
content: helpText,
|
||||
format: 'markdown'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/clear': async (args, context) => {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'clear',
|
||||
data: {
|
||||
message: 'Conversation history cleared'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/model': async (args, context) => {
|
||||
// Read available models from centralized constants
|
||||
const availableModels = {
|
||||
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_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'model',
|
||||
data: {
|
||||
current: {
|
||||
provider: currentProvider,
|
||||
model: currentModel
|
||||
},
|
||||
available: availableModels,
|
||||
message: args.length > 0
|
||||
? `Switching to model: ${args[0]}`
|
||||
: `Current model: ${currentModel}`
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/status': async (args, context) => {
|
||||
// Read version from package.json
|
||||
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
||||
let version = 'unknown';
|
||||
let packageName = 'claude-code-ui';
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
version = packageJson.version;
|
||||
packageName = packageJson.name;
|
||||
} catch (err) {
|
||||
console.error('Error reading package.json:', err);
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const uptimeMinutes = Math.floor(uptime / 60);
|
||||
const uptimeHours = Math.floor(uptimeMinutes / 60);
|
||||
const uptimeFormatted = uptimeHours > 0
|
||||
? `${uptimeHours}h ${uptimeMinutes % 60}m`
|
||||
: `${uptimeMinutes}m`;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'status',
|
||||
data: {
|
||||
version,
|
||||
packageName,
|
||||
uptime: uptimeFormatted,
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
model: context?.model || 'claude-sonnet-4.5',
|
||||
provider: context?.provider || 'claude',
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/memory': async (args, context) => {
|
||||
const projectPath = context?.projectPath;
|
||||
|
||||
if (!projectPath) {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'memory',
|
||||
data: {
|
||||
error: 'No project selected',
|
||||
message: 'Please select a project to access its CLAUDE.md file'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
|
||||
|
||||
// Check if CLAUDE.md exists
|
||||
let exists = false;
|
||||
try {
|
||||
await fs.access(claudeMdPath);
|
||||
exists = true;
|
||||
} catch (err) {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'memory',
|
||||
data: {
|
||||
path: claudeMdPath,
|
||||
exists,
|
||||
message: exists
|
||||
? `Opening CLAUDE.md at ${claudeMdPath}`
|
||||
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/config': async (args, context) => {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'config',
|
||||
data: {
|
||||
message: 'Opening settings...'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/rewind': async (args, context) => {
|
||||
const steps = args[0] ? parseInt(args[0]) : 1;
|
||||
|
||||
if (isNaN(steps) || steps < 1) {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'rewind',
|
||||
data: {
|
||||
error: 'Invalid steps parameter',
|
||||
message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'rewind',
|
||||
data: {
|
||||
steps,
|
||||
message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/commands/list
|
||||
* List all available commands from project and user directories
|
||||
*/
|
||||
router.post('/list', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
const allCommands = [...builtInCommands];
|
||||
|
||||
// Scan project-level commands (.claude/commands/)
|
||||
if (projectPath) {
|
||||
const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
|
||||
const projectCommands = await scanCommandsDirectory(
|
||||
projectCommandsDir,
|
||||
projectCommandsDir,
|
||||
'project'
|
||||
);
|
||||
allCommands.push(...projectCommands);
|
||||
}
|
||||
|
||||
// Scan user-level commands (~/.claude/commands/)
|
||||
const homeDir = os.homedir();
|
||||
const userCommandsDir = path.join(homeDir, '.claude', 'commands');
|
||||
const userCommands = await scanCommandsDirectory(
|
||||
userCommandsDir,
|
||||
userCommandsDir,
|
||||
'user'
|
||||
);
|
||||
allCommands.push(...userCommands);
|
||||
|
||||
// Separate built-in and custom commands
|
||||
const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
|
||||
|
||||
// Sort commands alphabetically by name
|
||||
customCommands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
res.json({
|
||||
builtIn: builtInCommands,
|
||||
custom: customCommands,
|
||||
count: allCommands.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing commands:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to list commands',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/commands/load
|
||||
* Load a specific command file and return its content and metadata
|
||||
*/
|
||||
router.post('/load', async (req, res) => {
|
||||
try {
|
||||
const { commandPath } = req.body;
|
||||
|
||||
if (!commandPath) {
|
||||
return res.status(400).json({
|
||||
error: 'Command path is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Security: Prevent path traversal
|
||||
const resolvedPath = path.resolve(commandPath);
|
||||
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
|
||||
!resolvedPath.includes('.claude/commands')) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Command must be in .claude/commands directory'
|
||||
});
|
||||
}
|
||||
|
||||
// Read and parse the command file
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = matter(content);
|
||||
|
||||
res.json({
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: commandContent
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({
|
||||
error: 'Command not found',
|
||||
message: `Command file not found: ${req.body.commandPath}`
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Error loading command:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to load command',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/commands/execute
|
||||
* Execute a command with argument replacement
|
||||
* This endpoint prepares the command content but doesn't execute bash commands yet
|
||||
* (that will be handled in the command parser utility)
|
||||
*/
|
||||
router.post('/execute', async (req, res) => {
|
||||
try {
|
||||
const { commandName, commandPath, args = [], context = {} } = req.body;
|
||||
|
||||
if (!commandName) {
|
||||
return res.status(400).json({
|
||||
error: 'Command name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle built-in commands
|
||||
const handler = builtInHandlers[commandName];
|
||||
if (handler) {
|
||||
try {
|
||||
const result = await handler(args, context);
|
||||
return res.json({
|
||||
...result,
|
||||
command: commandName
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error executing built-in command ${commandName}:`, error);
|
||||
return res.status(500).json({
|
||||
error: 'Command execution failed',
|
||||
message: error.message,
|
||||
command: commandName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom commands
|
||||
if (!commandPath) {
|
||||
return res.status(400).json({
|
||||
error: 'Command path is required for custom commands'
|
||||
});
|
||||
}
|
||||
|
||||
// Load command content
|
||||
// Security: validate commandPath is within allowed directories
|
||||
{
|
||||
const resolvedPath = path.resolve(commandPath);
|
||||
const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
|
||||
const projectBase = context?.projectPath
|
||||
? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
|
||||
: null;
|
||||
const isUnder = (base) => {
|
||||
const rel = path.relative(base, resolvedPath);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
};
|
||||
if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Command must be in .claude/commands directory'
|
||||
});
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = matter(content);
|
||||
// Basic argument replacement (will be enhanced in command parser utility)
|
||||
let processedContent = commandContent;
|
||||
|
||||
// Replace $ARGUMENTS with all arguments joined
|
||||
const argsString = args.join(' ');
|
||||
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
|
||||
|
||||
// Replace $1, $2, etc. with positional arguments
|
||||
args.forEach((arg, index) => {
|
||||
const placeholder = `$${index + 1}`;
|
||||
processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
|
||||
});
|
||||
|
||||
res.json({
|
||||
type: 'custom',
|
||||
command: commandName,
|
||||
content: processedContent,
|
||||
metadata,
|
||||
hasFileIncludes: processedContent.includes('@'),
|
||||
hasBashCommands: processedContent.includes('!')
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({
|
||||
error: 'Command not found',
|
||||
message: `Command file not found: ${req.body.commandPath}`
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Error executing command:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to execute command',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
795
server/routes/cursor.js
Normal file
@@ -0,0 +1,795 @@
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
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();
|
||||
|
||||
// GET /api/cursor/config - Read Cursor CLI configuration
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: config,
|
||||
path: configPath
|
||||
});
|
||||
} catch (error) {
|
||||
// Config doesn't exist or is invalid
|
||||
console.log('Cursor config not found or invalid:', error.message);
|
||||
|
||||
// Return default config
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: "GPT-5"
|
||||
},
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: []
|
||||
}
|
||||
},
|
||||
isDefault: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/config - Update Cursor CLI configuration
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { permissions, model } = req.body;
|
||||
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
|
||||
// Read existing config or create default
|
||||
let config = {
|
||||
version: 1,
|
||||
editor: {
|
||||
vimMode: false
|
||||
},
|
||||
hasChangedDefaultModel: false,
|
||||
privacyCache: {
|
||||
ghostMode: false,
|
||||
privacyMode: 3,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, 'utf8');
|
||||
config = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
// Config doesn't exist, use defaults
|
||||
console.log('Creating new Cursor config');
|
||||
}
|
||||
|
||||
// Update permissions if provided
|
||||
if (permissions) {
|
||||
config.permissions = {
|
||||
allow: permissions.allow || [],
|
||||
deny: permissions.deny || []
|
||||
};
|
||||
}
|
||||
|
||||
// Update model if provided
|
||||
if (model) {
|
||||
config.model = model;
|
||||
config.hasChangedDefaultModel = true;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const configDir = path.dirname(configPath);
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: config,
|
||||
message: 'Cursor configuration updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating Cursor config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to update Cursor configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
|
||||
router.get('/mcp', async (req, res) => {
|
||||
try {
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
try {
|
||||
const mcpContent = await fs.readFile(mcpPath, 'utf8');
|
||||
const mcpConfig = JSON.parse(mcpContent);
|
||||
|
||||
// Convert to UI-friendly format
|
||||
const servers = [];
|
||||
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
|
||||
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
|
||||
const server = {
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'cursor',
|
||||
config: {},
|
||||
raw: config
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
servers: servers,
|
||||
path: mcpPath
|
||||
});
|
||||
} catch (error) {
|
||||
// MCP config doesn't exist
|
||||
console.log('Cursor MCP config not found:', error.message);
|
||||
res.json({
|
||||
success: true,
|
||||
servers: [],
|
||||
isDefault: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor MCP config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor MCP configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
|
||||
router.post('/mcp/add', async (req, res) => {
|
||||
try {
|
||||
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`➕ Adding MCP server to Cursor config: ${name}`);
|
||||
|
||||
// Read existing config or create new
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
if (!mcpConfig.mcpServers) {
|
||||
mcpConfig.mcpServers = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Creating new Cursor MCP config');
|
||||
}
|
||||
|
||||
// Build server config based on type
|
||||
let serverConfig = {};
|
||||
|
||||
if (type === 'stdio') {
|
||||
serverConfig = {
|
||||
command: command,
|
||||
args: args,
|
||||
env: env
|
||||
};
|
||||
} else if (type === 'http' || type === 'sse') {
|
||||
serverConfig = {
|
||||
url: url,
|
||||
transport: type,
|
||||
headers: headers
|
||||
};
|
||||
}
|
||||
|
||||
// Add server to config
|
||||
mcpConfig.mcpServers[name] = serverConfig;
|
||||
|
||||
// Ensure directory exists
|
||||
const mcpDir = path.dirname(mcpPath);
|
||||
await fs.mkdir(mcpDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" added to Cursor configuration`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server to Cursor:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to add MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
|
||||
router.delete('/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
|
||||
|
||||
// Read existing config
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'Cursor MCP configuration not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if server exists
|
||||
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server "${name}" not found in Cursor configuration`
|
||||
});
|
||||
}
|
||||
|
||||
// Remove server from config
|
||||
delete mcpConfig.mcpServers[name];
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" removed from Cursor configuration`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing MCP server from Cursor:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to remove MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
|
||||
router.post('/mcp/add-json', async (req, res) => {
|
||||
try {
|
||||
const { name, jsonConfig } = req.body;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`➕ Adding MCP server to Cursor config via JSON: ${name}`);
|
||||
|
||||
// Validate and parse JSON config
|
||||
let parsedConfig;
|
||||
try {
|
||||
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid JSON configuration',
|
||||
details: parseError.message
|
||||
});
|
||||
}
|
||||
|
||||
// Read existing config or create new
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
if (!mcpConfig.mcpServers) {
|
||||
mcpConfig.mcpServers = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Creating new Cursor MCP config');
|
||||
}
|
||||
|
||||
// Add server to config
|
||||
mcpConfig.mcpServers[name] = parsedConfig;
|
||||
|
||||
// Ensure directory exists
|
||||
const mcpDir = path.dirname(mcpPath);
|
||||
await fs.mkdir(mcpDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" added to Cursor configuration via JSON`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server to Cursor via JSON:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to add MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(cursorChatsPath);
|
||||
} catch (error) {
|
||||
// No sessions for this project
|
||||
return res.json({
|
||||
success: true,
|
||||
sessions: [],
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
});
|
||||
}
|
||||
|
||||
// List all session directories
|
||||
const sessionDirs = await fs.readdir(cursorChatsPath);
|
||||
const sessions = [];
|
||||
|
||||
for (const sessionId of sessionDirs) {
|
||||
const sessionPath = path.join(cursorChatsPath, sessionId);
|
||||
const storeDbPath = path.join(sessionPath, 'store.db');
|
||||
let dbStatMtimeMs = null;
|
||||
|
||||
try {
|
||||
// Check if store.db exists
|
||||
await fs.access(storeDbPath);
|
||||
|
||||
// Capture store.db mtime as a reliable fallback timestamp (last activity)
|
||||
try {
|
||||
const stat = await fs.stat(storeDbPath);
|
||||
dbStatMtimeMs = stat.mtimeMs;
|
||||
} catch (_) {}
|
||||
|
||||
// Open SQLite database
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY
|
||||
});
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = await db.all(`
|
||||
SELECT key, value FROM meta
|
||||
`);
|
||||
|
||||
let sessionData = {
|
||||
id: sessionId,
|
||||
name: 'Untitled Session',
|
||||
createdAt: null,
|
||||
mode: null,
|
||||
projectPath: projectPath,
|
||||
lastMessage: null,
|
||||
messageCount: 0
|
||||
};
|
||||
|
||||
// Parse meta table entries
|
||||
for (const row of metaRows) {
|
||||
if (row.value) {
|
||||
try {
|
||||
// Try to decode as hex-encoded JSON
|
||||
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
||||
if (hexMatch) {
|
||||
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
if (row.key === 'agent') {
|
||||
sessionData.name = data.name || sessionData.name;
|
||||
// Normalize createdAt to ISO string in milliseconds
|
||||
let createdAt = data.createdAt;
|
||||
if (typeof createdAt === 'number') {
|
||||
if (createdAt < 1e12) {
|
||||
createdAt = createdAt * 1000; // seconds -> ms
|
||||
}
|
||||
sessionData.createdAt = new Date(createdAt).toISOString();
|
||||
} else if (typeof createdAt === 'string') {
|
||||
const n = Number(createdAt);
|
||||
if (!Number.isNaN(n)) {
|
||||
const ms = n < 1e12 ? n * 1000 : n;
|
||||
sessionData.createdAt = new Date(ms).toISOString();
|
||||
} else {
|
||||
// Assume it's already an ISO/date string
|
||||
const d = new Date(createdAt);
|
||||
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
} else {
|
||||
sessionData.createdAt = sessionData.createdAt || null;
|
||||
}
|
||||
sessionData.mode = data.mode;
|
||||
sessionData.agentId = data.agentId;
|
||||
sessionData.latestRootBlobId = data.latestRootBlobId;
|
||||
}
|
||||
} else {
|
||||
// If not hex, use raw value for simple keys
|
||||
if (row.key === 'name') {
|
||||
sessionData.name = row.value.toString();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get message count from JSON blobs only (actual messages, not DAG structure)
|
||||
try {
|
||||
const blobCount = await db.get(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM blobs
|
||||
WHERE substr(data, 1, 1) = X'7B'
|
||||
`);
|
||||
sessionData.messageCount = blobCount.count;
|
||||
|
||||
// Get the most recent JSON blob for preview (actual message, not DAG structure)
|
||||
const lastBlob = await db.get(`
|
||||
SELECT data FROM blobs
|
||||
WHERE substr(data, 1, 1) = X'7B'
|
||||
ORDER BY rowid DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (lastBlob && lastBlob.data) {
|
||||
try {
|
||||
// Try to extract readable preview from blob (may contain binary with embedded JSON)
|
||||
const raw = lastBlob.data.toString('utf8');
|
||||
let preview = '';
|
||||
// Attempt direct JSON parse
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.content) {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||
preview = firstText;
|
||||
} else if (typeof parsed.content === 'string') {
|
||||
preview = parsed.content;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!preview) {
|
||||
// Strip non-printable and try to find JSON chunk
|
||||
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
|
||||
const s = cleaned;
|
||||
const start = s.indexOf('{');
|
||||
const end = s.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
const jsonStr = s.slice(start, end + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
if (parsed?.content) {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||
preview = firstText;
|
||||
} else if (typeof parsed.content === 'string') {
|
||||
preview = parsed.content;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
preview = s;
|
||||
}
|
||||
} else {
|
||||
preview = s;
|
||||
}
|
||||
}
|
||||
if (preview && preview.length > 0) {
|
||||
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not parse blob data:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not read blobs:', e.message);
|
||||
}
|
||||
|
||||
await db.close();
|
||||
|
||||
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
|
||||
if (!sessionData.createdAt) {
|
||||
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
|
||||
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push(sessionData);
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Could not read session ${sessionId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
|
||||
for (const s of sessions) {
|
||||
if (!s.createdAt) {
|
||||
try {
|
||||
const sessionDir = path.join(cursorChatsPath, s.id);
|
||||
const st = await fs.stat(sessionDir);
|
||||
s.createdAt = new Date(st.mtimeMs).toISOString();
|
||||
} catch {
|
||||
s.createdAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort sessions by creation date (newest first)
|
||||
sessions.sort((a, b) => {
|
||||
if (!a.createdAt) return 1;
|
||||
if (!b.createdAt) return -1;
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessions: sessions,
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor sessions:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor sessions',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite
|
||||
router.get('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const { projectPath } = req.query;
|
||||
|
||||
// Calculate cwdID hash for the project path
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||
|
||||
|
||||
// Open SQLite database
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY
|
||||
});
|
||||
|
||||
// Get all blobs to build the DAG structure
|
||||
const allBlobs = await db.all(`
|
||||
SELECT rowid, id, data FROM blobs
|
||||
`);
|
||||
|
||||
// Build the DAG structure from parent-child relationships
|
||||
const blobMap = new Map(); // id -> blob data
|
||||
const parentRefs = new Map(); // blob id -> [parent blob ids]
|
||||
const childRefs = new Map(); // blob id -> [child blob ids]
|
||||
const jsonBlobs = []; // Clean JSON messages
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
blobMap.set(blob.id, blob);
|
||||
|
||||
// Check if this is a JSON blob (actual message) or protobuf (DAG structure)
|
||||
if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob
|
||||
try {
|
||||
const parsed = JSON.parse(blob.data.toString('utf8'));
|
||||
jsonBlobs.push({ ...blob, parsed });
|
||||
} catch (e) {
|
||||
console.log('Failed to parse JSON blob:', blob.rowid);
|
||||
}
|
||||
} else if (blob.data) { // Protobuf blob - extract parent references
|
||||
const parents = [];
|
||||
let i = 0;
|
||||
|
||||
// Scan for parent references (0x0A 0x20 followed by 32-byte hash)
|
||||
while (i < blob.data.length - 33) {
|
||||
if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) {
|
||||
const parentHash = blob.data.slice(i+2, i+34).toString('hex');
|
||||
if (blobMap.has(parentHash)) {
|
||||
parents.push(parentHash);
|
||||
}
|
||||
i += 34;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (parents.length > 0) {
|
||||
parentRefs.set(blob.id, parents);
|
||||
// Update child references
|
||||
for (const parentId of parents) {
|
||||
if (!childRefs.has(parentId)) {
|
||||
childRefs.set(parentId, []);
|
||||
}
|
||||
childRefs.get(parentId).push(blob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform topological sort to get chronological order
|
||||
const visited = new Set();
|
||||
const sorted = [];
|
||||
|
||||
// DFS-based topological sort
|
||||
function visit(nodeId) {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
|
||||
// Visit all parents first (dependencies)
|
||||
const parents = parentRefs.get(nodeId) || [];
|
||||
for (const parentId of parents) {
|
||||
visit(parentId);
|
||||
}
|
||||
|
||||
// Add this node after all its parents
|
||||
const blob = blobMap.get(nodeId);
|
||||
if (blob) {
|
||||
sorted.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
// Start with nodes that have no parents (roots)
|
||||
for (const blob of allBlobs) {
|
||||
if (!parentRefs.has(blob.id)) {
|
||||
visit(blob.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Visit any remaining nodes (disconnected components)
|
||||
for (const blob of allBlobs) {
|
||||
visit(blob.id);
|
||||
}
|
||||
|
||||
// Now extract JSON messages in the order they appear in the sorted DAG
|
||||
const messageOrder = new Map(); // JSON blob id -> order index
|
||||
let orderIndex = 0;
|
||||
|
||||
for (const blob of sorted) {
|
||||
// Check if this blob references any JSON messages
|
||||
if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob
|
||||
// Look for JSON blob references
|
||||
for (const jsonBlob of jsonBlobs) {
|
||||
try {
|
||||
const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex');
|
||||
if (blob.data.includes(jsonIdBytes)) {
|
||||
if (!messageOrder.has(jsonBlob.id)) {
|
||||
messageOrder.set(jsonBlob.id, orderIndex++);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip if can't convert ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort JSON blobs by their appearance order in the DAG
|
||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||
const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
// Fallback to rowid if not in order map
|
||||
return a.rowid - b.rowid;
|
||||
});
|
||||
|
||||
// Use sorted JSON blobs
|
||||
const blobs = sortedJsonBlobs.map((blob, idx) => ({
|
||||
...blob,
|
||||
sequence_num: idx + 1,
|
||||
original_rowid: blob.rowid
|
||||
}));
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = await db.all(`
|
||||
SELECT key, value FROM meta
|
||||
`);
|
||||
|
||||
// Parse metadata
|
||||
let metadata = {};
|
||||
for (const row of metaRows) {
|
||||
if (row.value) {
|
||||
try {
|
||||
// Try to decode as hex-encoded JSON
|
||||
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
||||
if (hexMatch) {
|
||||
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
||||
metadata[row.key] = JSON.parse(jsonStr);
|
||||
} else {
|
||||
metadata[row.key] = row.value.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
metadata[row.key] = row.value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract messages from sorted JSON blobs
|
||||
const messages = [];
|
||||
for (const blob of blobs) {
|
||||
try {
|
||||
// We already parsed JSON blobs earlier
|
||||
const parsed = blob.parsed;
|
||||
|
||||
if (parsed) {
|
||||
// Filter out ONLY system messages at the server level
|
||||
// Check both direct role and nested message.role
|
||||
const role = parsed?.role || parsed?.message?.role;
|
||||
if (role === 'system') {
|
||||
continue; // Skip only system messages
|
||||
}
|
||||
messages.push({
|
||||
id: blob.id,
|
||||
sequence: blob.sequence_num,
|
||||
rowid: blob.original_rowid,
|
||||
content: parsed
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip blobs that cause errors
|
||||
console.log(`Skipping blob ${blob.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await db.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
session: {
|
||||
id: sessionId,
|
||||
projectPath: projectPath,
|
||||
messages: messages,
|
||||
metadata: metadata,
|
||||
cwdId: cwdId
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor session:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor session',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,8 @@ 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);
|
||||
@@ -19,6 +21,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 {
|
||||
@@ -49,35 +80,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);
|
||||
console.log('Git status for project:', project, '-> path:', projectPath);
|
||||
|
||||
|
||||
// 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') {
|
||||
@@ -88,9 +131,10 @@ router.get('/status', async (req, res) => {
|
||||
untracked.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
branch: branch.trim(),
|
||||
branch,
|
||||
hasCommits,
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
@@ -98,9 +142,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
|
||||
@@ -123,29 +167,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
|
||||
const { stdout } = await execAsync(`git diff HEAD -- "${file}"`, { cwd: projectPath });
|
||||
diff = stdout || '';
|
||||
|
||||
// If no unstaged changes, check for staged changes
|
||||
if (!diff) {
|
||||
// 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 = 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);
|
||||
@@ -153,6 +215,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;
|
||||
@@ -192,7 +361,6 @@ router.get('/branches', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
console.log('Git branches for project:', project, '-> path:', projectPath);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
@@ -342,19 +510,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(
|
||||
@@ -362,17 +535,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);
|
||||
@@ -380,46 +572,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;
|
||||
@@ -765,10 +1056,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 });
|
||||
@@ -809,10 +1107,18 @@ 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 });
|
||||
|
||||
48
server/routes/mcp-utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* MCP UTILITIES API ROUTES
|
||||
* ========================
|
||||
*
|
||||
* API endpoints for MCP server detection and configuration utilities.
|
||||
* These endpoints expose centralized MCP detection functionality.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/mcp-utils/taskmaster-server
|
||||
* Check if TaskMaster MCP server is configured
|
||||
*/
|
||||
router.get('/taskmaster-server', async (req, res) => {
|
||||
try {
|
||||
const result = await detectTaskMasterMCPServer();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('TaskMaster MCP detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to detect TaskMaster MCP server',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mcp-utils/all-servers
|
||||
* Get all configured MCP servers
|
||||
*/
|
||||
router.get('/all-servers', async (req, res) => {
|
||||
try {
|
||||
const result = await getAllMCPServers();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('MCP servers detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get MCP servers',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
378
server/routes/projects.js
Normal file
@@ -0,0 +1,378 @@
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
import { addProjectManually } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure allowed workspace root (defaults to user's home directory)
|
||||
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
const FORBIDDEN_PATHS = [
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
'/usr',
|
||||
'/dev',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/var',
|
||||
'/boot',
|
||||
'/root',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates that a path is safe for workspace operations
|
||||
* @param {string} requestedPath - The path to validate
|
||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||
*/
|
||||
async function validateWorkspacePath(requestedPath) {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
let absolutePath = path.resolve(requestedPath);
|
||||
|
||||
// Check if path is a forbidden system directory
|
||||
const normalizedPath = path.normalize(absolutePath);
|
||||
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot use system-critical directories as workspace locations'
|
||||
};
|
||||
}
|
||||
|
||||
// Additional check for paths starting with forbidden directories
|
||||
for (const forbidden of FORBIDDEN_PATHS) {
|
||||
if (normalizedPath === forbidden ||
|
||||
normalizedPath.startsWith(forbidden + path.sep)) {
|
||||
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
||||
// but /var itself and most /var subdirectories should be blocked
|
||||
if (forbidden === '/var' &&
|
||||
(normalizedPath.startsWith('/var/tmp') ||
|
||||
normalizedPath.startsWith('/var/folders'))) {
|
||||
continue; // Allow these specific cases
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot create workspace in system directory: ${forbidden}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to resolve the real path (following symlinks)
|
||||
let realPath;
|
||||
try {
|
||||
// Check if path exists to resolve real path
|
||||
await fs.access(absolutePath);
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Path doesn't exist yet - check parent directory
|
||||
let parentPath = path.dirname(absolutePath);
|
||||
try {
|
||||
const parentRealPath = await fs.realpath(parentPath);
|
||||
|
||||
// Reconstruct the full path with real parent
|
||||
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
||||
} catch (parentError) {
|
||||
if (parentError.code === 'ENOENT') {
|
||||
// Parent doesn't exist either - use the absolute path as-is
|
||||
// We'll validate it's within allowed root
|
||||
realPath = absolutePath;
|
||||
} else {
|
||||
throw parentError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the workspace root to its real path
|
||||
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
||||
|
||||
// Ensure the resolved path is contained within the allowed workspace root
|
||||
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||
realPath !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
|
||||
};
|
||||
}
|
||||
|
||||
// Additional symlink check for existing paths
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.lstat(absolutePath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Verify symlink target is also within allowed root
|
||||
const linkTarget = await fs.readlink(absolutePath);
|
||||
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
||||
const realTarget = await fs.realpath(resolvedTarget);
|
||||
|
||||
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||
realTarget !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Symlink target is outside the allowed workspace root'
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Path doesn't exist - that's fine for new workspace creation
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
resolvedPath: realPath
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Path validation failed: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new workspace
|
||||
* POST /api/projects/create-workspace
|
||||
*
|
||||
* Body:
|
||||
* - workspaceType: 'existing' | 'new'
|
||||
* - path: string (workspace path)
|
||||
* - githubUrl?: string (optional, for new workspaces)
|
||||
* - githubTokenId?: number (optional, ID of stored token)
|
||||
* - newGithubToken?: string (optional, one-time token)
|
||||
*/
|
||||
router.post('/create-workspace', async (req, res) => {
|
||||
try {
|
||||
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!workspaceType || !workspacePath) {
|
||||
return res.status(400).json({ error: 'workspaceType and path are required' });
|
||||
}
|
||||
|
||||
if (!['existing', 'new'].includes(workspaceType)) {
|
||||
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
|
||||
}
|
||||
|
||||
// Validate path safety before any operations
|
||||
const validation = await validateWorkspacePath(workspacePath);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid workspace path',
|
||||
details: validation.error
|
||||
});
|
||||
}
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
// Handle existing workspace
|
||||
if (workspaceType === 'existing') {
|
||||
// Check if the path exists
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Workspace path does not exist' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Add the existing workspace to the project list
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'Existing workspace added successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle new workspace creation
|
||||
if (workspaceType === 'new') {
|
||||
// Check if path already exists
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
return res.status(400).json({
|
||||
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Path doesn't exist - good, we can create it
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
// If GitHub URL is provided, clone the repository
|
||||
if (githubUrl) {
|
||||
let githubToken = null;
|
||||
|
||||
// Get GitHub token if needed
|
||||
if (githubTokenId) {
|
||||
// Fetch token from database
|
||||
const token = await getGithubTokenById(githubTokenId, req.user.id);
|
||||
if (!token) {
|
||||
// Clean up created directory
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
return res.status(404).json({ error: 'GitHub token not found' });
|
||||
}
|
||||
githubToken = token.github_token;
|
||||
} else if (newGithubToken) {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
// Clone the repository
|
||||
try {
|
||||
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
|
||||
} catch (error) {
|
||||
// Clean up created directory on failure
|
||||
try {
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up directory after clone failure:', cleanupError);
|
||||
// Continue to throw original error
|
||||
}
|
||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new workspace to the project list
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: githubUrl
|
||||
? 'New workspace created and repository cloned successfully'
|
||||
: 'New workspace created successfully'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
res.status(500).json({
|
||||
error: error.message || 'Failed to create workspace',
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get GitHub token from database
|
||||
*/
|
||||
async function getGithubTokenById(tokenId, userId) {
|
||||
const { getDatabase } = await import('../database/db.js');
|
||||
const db = await getDatabase();
|
||||
|
||||
const credential = await db.get(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||
[tokenId, userId, 'github_token']
|
||||
);
|
||||
|
||||
// Return in the expected format (github_token field for compatibility)
|
||||
if (credential) {
|
||||
return {
|
||||
...credential,
|
||||
github_token: credential.credential_value
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to clone a GitHub repository
|
||||
*/
|
||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Parse GitHub URL and inject token if provided
|
||||
let cloneUrl = githubUrl;
|
||||
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
// Format: https://TOKEN@github.com/user/repo.git
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
return reject(new Error('Invalid GitHub URL format'));
|
||||
}
|
||||
}
|
||||
|
||||
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
|
||||
}
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
gitProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
// Parse git error messages to provide helpful feedback
|
||||
let errorMessage = 'Git clone failed';
|
||||
|
||||
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
||||
errorMessage = 'Authentication failed. Please check your GitHub token.';
|
||||
} else if (stderr.includes('Repository not found')) {
|
||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||
} else if (stderr.includes('already exists')) {
|
||||
errorMessage = 'Directory already exists';
|
||||
} else if (stderr) {
|
||||
errorMessage = stderr;
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
reject(new Error('Git is not installed or not in PATH'));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
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;
|
||||
1963
server/routes/taskmaster.js
Normal file
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;
|
||||
303
server/utils/commandParser.js
Normal file
@@ -0,0 +1,303 @@
|
||||
import matter from 'gray-matter';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { parse as parseShellCommand } from 'shell-quote';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Configuration
|
||||
const MAX_INCLUDE_DEPTH = 3;
|
||||
const BASH_TIMEOUT = 30000; // 30 seconds
|
||||
const BASH_COMMAND_ALLOWLIST = [
|
||||
'echo',
|
||||
'ls',
|
||||
'pwd',
|
||||
'date',
|
||||
'whoami',
|
||||
'git',
|
||||
'npm',
|
||||
'node',
|
||||
'cat',
|
||||
'grep',
|
||||
'find',
|
||||
'task-master'
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a markdown command file and extract frontmatter and content
|
||||
* @param {string} content - Raw markdown content
|
||||
* @returns {object} Parsed command with data (frontmatter) and content
|
||||
*/
|
||||
export function parseCommand(content) {
|
||||
try {
|
||||
const parsed = matter(content);
|
||||
return {
|
||||
data: parsed.data || {},
|
||||
content: parsed.content || '',
|
||||
raw: content
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse command: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace argument placeholders in content
|
||||
* @param {string} content - Content with placeholders
|
||||
* @param {string|array} args - Arguments to replace (string or array)
|
||||
* @returns {string} Content with replaced arguments
|
||||
*/
|
||||
export function replaceArguments(content, args) {
|
||||
if (!content) return content;
|
||||
|
||||
let result = content;
|
||||
|
||||
// Convert args to array if it's a string
|
||||
const argsArray = Array.isArray(args) ? args : (args ? [args] : []);
|
||||
|
||||
// Replace $ARGUMENTS with all arguments joined by space
|
||||
const allArgs = argsArray.join(' ');
|
||||
result = result.replace(/\$ARGUMENTS/g, allArgs);
|
||||
|
||||
// Replace positional arguments $1-$9
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const regex = new RegExp(`\\$${i}`, 'g');
|
||||
const value = argsArray[i - 1] || '';
|
||||
result = result.replace(regex, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file path to prevent directory traversal
|
||||
* @param {string} filePath - Path to validate
|
||||
* @param {string} basePath - Base directory path
|
||||
* @returns {boolean} True if path is safe
|
||||
*/
|
||||
export function isPathSafe(filePath, basePath) {
|
||||
const resolvedPath = path.resolve(basePath, filePath);
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
const relative = path.relative(resolvedBase, resolvedPath);
|
||||
return (
|
||||
relative !== '' &&
|
||||
!relative.startsWith('..') &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process file includes in content (@filename syntax)
|
||||
* @param {string} content - Content with @filename includes
|
||||
* @param {string} basePath - Base directory for resolving file paths
|
||||
* @param {number} depth - Current recursion depth
|
||||
* @returns {Promise<string>} Content with includes resolved
|
||||
*/
|
||||
export async function processFileIncludes(content, basePath, depth = 0) {
|
||||
if (!content) return content;
|
||||
|
||||
// Prevent infinite recursion
|
||||
if (depth >= MAX_INCLUDE_DEPTH) {
|
||||
throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`);
|
||||
}
|
||||
|
||||
// Match @filename patterns (at start of line or after whitespace)
|
||||
const includePattern = /(?:^|\s)@([^\s]+)/gm;
|
||||
const matches = [...content.matchAll(includePattern)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let result = content;
|
||||
|
||||
for (const match of matches) {
|
||||
const fullMatch = match[0];
|
||||
const filename = match[1];
|
||||
|
||||
// Security: prevent directory traversal
|
||||
if (!isPathSafe(filename, basePath)) {
|
||||
throw new Error(`Invalid file path (directory traversal detected): ${filename}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.resolve(basePath, filename);
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
// Recursively process includes in the included file
|
||||
const processedContent = await processFileIncludes(fileContent, basePath, depth + 1);
|
||||
|
||||
// Replace the @filename with the file content
|
||||
result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`File not found: ${filename}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a command and its arguments are safe
|
||||
* @param {string} commandString - Command string to validate
|
||||
* @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result
|
||||
*/
|
||||
export function validateCommand(commandString) {
|
||||
const trimmedCommand = commandString.trim();
|
||||
if (!trimmedCommand) {
|
||||
return { allowed: false, command: '', args: [], error: 'Empty command' };
|
||||
}
|
||||
|
||||
// Parse the command using shell-quote to handle quotes properly
|
||||
const parsed = parseShellCommand(trimmedCommand);
|
||||
|
||||
// Check for shell operators or control structures
|
||||
const hasOperators = parsed.some(token =>
|
||||
typeof token === 'object' && token.op
|
||||
);
|
||||
|
||||
if (hasOperators) {
|
||||
return {
|
||||
allowed: false,
|
||||
command: '',
|
||||
args: [],
|
||||
error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract command and args (all should be strings after validation)
|
||||
const tokens = parsed.filter(token => typeof token === 'string');
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return { allowed: false, command: '', args: [], error: 'No valid command found' };
|
||||
}
|
||||
|
||||
const [command, ...args] = tokens;
|
||||
|
||||
// Extract just the command name (remove path if present)
|
||||
const commandName = path.basename(command);
|
||||
|
||||
// Check if command exactly matches allowlist (no prefix matching)
|
||||
const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName);
|
||||
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
command: commandName,
|
||||
args,
|
||||
error: `Command '${commandName}' is not in the allowlist`
|
||||
};
|
||||
}
|
||||
|
||||
// Validate arguments don't contain dangerous metacharacters
|
||||
const dangerousPattern = /[;&|`$()<>{}[\]\\]/;
|
||||
for (const arg of args) {
|
||||
if (dangerousPattern.test(arg)) {
|
||||
return {
|
||||
allowed: false,
|
||||
command: commandName,
|
||||
args,
|
||||
error: `Argument contains dangerous characters: ${arg}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, command: commandName, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility: Check if command is allowed (deprecated)
|
||||
* @deprecated Use validateCommand() instead for better security
|
||||
* @param {string} command - Command to validate
|
||||
* @returns {boolean} True if command is allowed
|
||||
*/
|
||||
export function isBashCommandAllowed(command) {
|
||||
const result = validateCommand(command);
|
||||
return result.allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize bash command output
|
||||
* @param {string} output - Raw command output
|
||||
* @returns {string} Sanitized output
|
||||
*/
|
||||
export function sanitizeOutput(output) {
|
||||
if (!output) return '';
|
||||
|
||||
// Remove control characters except \t, \n, \r
|
||||
return [...output]
|
||||
.filter(ch => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code === 9 // \t
|
||||
|| code === 10 // \n
|
||||
|| code === 13 // \r
|
||||
|| (code >= 32 && code !== 127);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process bash commands in content (!command syntax)
|
||||
* @param {string} content - Content with !command syntax
|
||||
* @param {object} options - Options for bash execution
|
||||
* @returns {Promise<string>} Content with bash commands executed and replaced
|
||||
*/
|
||||
export async function processBashCommands(content, options = {}) {
|
||||
if (!content) return content;
|
||||
|
||||
const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options;
|
||||
|
||||
// Match !command patterns (at start of line or after whitespace)
|
||||
const commandPattern = /(?:^|\n)!(.+?)(?=\n|$)/g;
|
||||
const matches = [...content.matchAll(commandPattern)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let result = content;
|
||||
|
||||
for (const match of matches) {
|
||||
const fullMatch = match[0];
|
||||
const commandString = match[1].trim();
|
||||
|
||||
// Security: validate command and parse args
|
||||
const validation = validateCommand(commandString);
|
||||
|
||||
if (!validation.allowed) {
|
||||
throw new Error(`Command not allowed: ${commandString} - ${validation.error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute without shell using execFile with parsed args
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
validation.command,
|
||||
validation.args,
|
||||
{
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024, // 1MB max output
|
||||
shell: false, // IMPORTANT: No shell interpretation
|
||||
env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands
|
||||
}
|
||||
);
|
||||
|
||||
const output = sanitizeOutput(stdout || stderr || '');
|
||||
|
||||
// Replace the !command with the output
|
||||
result = result.replace(fullMatch, fullMatch.startsWith('\n') ? '\n' + output : output);
|
||||
} catch (error) {
|
||||
if (error.killed) {
|
||||
throw new Error(`Command timeout: ${commandString}`);
|
||||
}
|
||||
throw new Error(`Command failed: ${commandString} - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
198
server/utils/mcp-detector.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* MCP SERVER DETECTION UTILITY
|
||||
* ============================
|
||||
*
|
||||
* Centralized utility for detecting MCP server configurations.
|
||||
* Used across TaskMaster integration and other MCP-dependent features.
|
||||
*/
|
||||
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if task-master-ai MCP server is configured
|
||||
* Reads directly from Claude configuration files like claude-cli.js does
|
||||
* @returns {Promise<Object>} MCP detection result
|
||||
*/
|
||||
export async function detectTaskMasterMCPServer() {
|
||||
try {
|
||||
// Read Claude configuration files directly (same logic as mcp.js)
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fsPromises.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
break;
|
||||
} catch (error) {
|
||||
// File doesn't exist or is not valid JSON, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return {
|
||||
hasMCPServer: false,
|
||||
reason: 'No Claude configuration file found',
|
||||
hasConfig: false
|
||||
};
|
||||
}
|
||||
|
||||
// Look for task-master-ai in user-scoped MCP servers
|
||||
let taskMasterServer = null;
|
||||
if (configData.mcpServers && typeof configData.mcpServers === 'object') {
|
||||
const serverEntry = Object.entries(configData.mcpServers).find(([name, config]) =>
|
||||
name === 'task-master-ai' ||
|
||||
name.includes('task-master') ||
|
||||
(config && config.command && config.command.includes('task-master'))
|
||||
);
|
||||
|
||||
if (serverEntry) {
|
||||
const [name, config] = serverEntry;
|
||||
taskMasterServer = {
|
||||
name,
|
||||
scope: 'user',
|
||||
config,
|
||||
type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Also check project-specific MCP servers if not found globally
|
||||
if (!taskMasterServer && configData.projects) {
|
||||
for (const [projectPath, projectConfig] of Object.entries(configData.projects)) {
|
||||
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
||||
const serverEntry = Object.entries(projectConfig.mcpServers).find(([name, config]) =>
|
||||
name === 'task-master-ai' ||
|
||||
name.includes('task-master') ||
|
||||
(config && config.command && config.command.includes('task-master'))
|
||||
);
|
||||
|
||||
if (serverEntry) {
|
||||
const [name, config] = serverEntry;
|
||||
taskMasterServer = {
|
||||
name,
|
||||
scope: 'local',
|
||||
projectPath,
|
||||
config,
|
||||
type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown')
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (taskMasterServer) {
|
||||
const isValid = !!(taskMasterServer.config &&
|
||||
(taskMasterServer.config.command || taskMasterServer.config.url));
|
||||
const hasEnvVars = !!(taskMasterServer.config &&
|
||||
taskMasterServer.config.env &&
|
||||
Object.keys(taskMasterServer.config.env).length > 0);
|
||||
|
||||
return {
|
||||
hasMCPServer: true,
|
||||
isConfigured: isValid,
|
||||
hasApiKeys: hasEnvVars,
|
||||
scope: taskMasterServer.scope,
|
||||
config: {
|
||||
command: taskMasterServer.config?.command,
|
||||
args: taskMasterServer.config?.args || [],
|
||||
url: taskMasterServer.config?.url,
|
||||
envVars: hasEnvVars ? Object.keys(taskMasterServer.config.env) : [],
|
||||
type: taskMasterServer.type
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Get list of available servers for debugging
|
||||
const availableServers = [];
|
||||
if (configData.mcpServers) {
|
||||
availableServers.push(...Object.keys(configData.mcpServers));
|
||||
}
|
||||
if (configData.projects) {
|
||||
for (const projectConfig of Object.values(configData.projects)) {
|
||||
if (projectConfig.mcpServers) {
|
||||
availableServers.push(...Object.keys(projectConfig.mcpServers).map(name => `local:${name}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasMCPServer: false,
|
||||
reason: 'task-master-ai not found in configured MCP servers',
|
||||
hasConfig: true,
|
||||
configPath,
|
||||
availableServers
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error detecting MCP server config:', error);
|
||||
return {
|
||||
hasMCPServer: false,
|
||||
reason: `Error checking MCP config: ${error.message}`,
|
||||
hasConfig: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured MCP servers (not just TaskMaster)
|
||||
* @returns {Promise<Object>} All MCP servers configuration
|
||||
*/
|
||||
export async function getAllMCPServers() {
|
||||
try {
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fsPromises.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
break;
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return {
|
||||
hasConfig: false,
|
||||
servers: {},
|
||||
projectServers: {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasConfig: true,
|
||||
configPath,
|
||||
servers: configData.mcpServers || {},
|
||||
projectServers: configData.projects || {}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting all MCP servers:', error);
|
||||
return {
|
||||
hasConfig: false,
|
||||
error: error.message,
|
||||
servers: {},
|
||||
projectServers: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
129
server/utils/taskmaster-websocket.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* TASKMASTER WEBSOCKET UTILITIES
|
||||
* ==============================
|
||||
*
|
||||
* Utilities for broadcasting TaskMaster state changes via WebSocket.
|
||||
* Integrates with the existing WebSocket system to provide real-time updates.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Broadcast TaskMaster project update to all connected clients
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {string} projectName - Name of the updated project
|
||||
* @param {Object} taskMasterData - Updated TaskMaster data
|
||||
*/
|
||||
export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterData) {
|
||||
if (!wss || !projectName) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-project-updated',
|
||||
projectName,
|
||||
taskMasterData,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster project update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast TaskMaster tasks update for a specific project
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {string} projectName - Name of the project with updated tasks
|
||||
* @param {Object} tasksData - Updated tasks data
|
||||
*/
|
||||
export function broadcastTaskMasterTasksUpdate(wss, projectName, tasksData) {
|
||||
if (!wss || !projectName) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-tasks-updated',
|
||||
projectName,
|
||||
tasksData,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster tasks update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast MCP server status change
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {Object} mcpStatus - Updated MCP server status
|
||||
*/
|
||||
export function broadcastMCPStatusChange(wss, mcpStatus) {
|
||||
if (!wss) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-mcp-status-changed',
|
||||
mcpStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster MCP status update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast general TaskMaster update notification
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {string} updateType - Type of update (e.g., 'initialization', 'configuration')
|
||||
* @param {Object} data - Additional data about the update
|
||||
*/
|
||||
export function broadcastTaskMasterUpdate(wss, updateType, data = {}) {
|
||||
if (!wss || !updateType) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss or updateType');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-update',
|
||||
updateType,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
65
shared/modelConstants.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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.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.2'
|
||||
};
|
||||
549
src/App.jsx
@@ -18,20 +18,24 @@
|
||||
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MainContent from './components/MainContent';
|
||||
import MobileNav from './components/MobileNav';
|
||||
import ToolsSettings from './components/ToolsSettings';
|
||||
import Settings from './components/Settings';
|
||||
import QuickSettingsPanel from './components/QuickSettingsPanel';
|
||||
|
||||
import { useWebSocket } from './utils/websocket';
|
||||
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 { api } from './utils/api';
|
||||
import useLocalStorage from './hooks/useLocalStorage';
|
||||
import { api, authenticatedFetch } from './utils/api';
|
||||
|
||||
|
||||
// Main App component with routing
|
||||
@@ -39,7 +43,7 @@ function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams();
|
||||
|
||||
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
@@ -50,31 +54,62 @@ function AppContent() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showToolsSettings, setShowToolsSettings] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
||||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||||
const [autoExpandTools, setAutoExpandTools] = useState(() => {
|
||||
const saved = localStorage.getItem('autoExpandTools');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [showRawParameters, setShowRawParameters] = useState(() => {
|
||||
const saved = localStorage.getItem('showRawParameters');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [autoScrollToBottom, setAutoScrollToBottom] = useState(() => {
|
||||
const saved = localStorage.getItem('autoScrollToBottom');
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
|
||||
const saved = localStorage.getItem('sendByCtrlEnter');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
|
||||
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
|
||||
const [showThinking, setShowThinking] = useLocalStorage('showThinking', true);
|
||||
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
|
||||
const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true);
|
||||
// Session Protection System: Track sessions with active conversations to prevent
|
||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||
// a message, the session is marked as "active" and project updates are paused
|
||||
// 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();
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocket();
|
||||
// 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);
|
||||
document.addEventListener('touchstart', {});
|
||||
|
||||
// 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 = () => {
|
||||
@@ -137,7 +172,30 @@ function AppContent() {
|
||||
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 normalized = latestMessage.changedFile.replace(/\\/g, '/');
|
||||
const changedFileParts = normalized.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
|
||||
setExternalMessageUpdate(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
@@ -164,21 +222,26 @@ function AppContent() {
|
||||
// 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) {
|
||||
setSelectedProject(updatedSelectedProject);
|
||||
|
||||
// Update selected session only if it was deleted - avoid unnecessary reloads
|
||||
// Only update selected project if it actually changed - prevents flickering
|
||||
if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) {
|
||||
setSelectedProject(updatedSelectedProject);
|
||||
}
|
||||
|
||||
if (selectedSession) {
|
||||
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
|
||||
const allSessions = [
|
||||
...(updatedSelectedProject.sessions || []),
|
||||
...(updatedSelectedProject.codexSessions || []),
|
||||
...(updatedSelectedProject.cursorSessions || [])
|
||||
];
|
||||
const updatedSelectedSession = allSessions.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +255,27 @@ function AppContent() {
|
||||
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
|
||||
@@ -210,7 +294,8 @@ function AppContent() {
|
||||
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.sessions) !== JSON.stringify(prevProject.sessions) ||
|
||||
JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions)
|
||||
);
|
||||
}) || data.length !== prevProjects.length;
|
||||
|
||||
@@ -229,6 +314,12 @@ function AppContent() {
|
||||
// Expose fetchProjects globally for component access
|
||||
window.refreshProjects = fetchProjects;
|
||||
|
||||
// Expose openSettings function globally for component access
|
||||
window.openSettings = useCallback((tab = 'tools') => {
|
||||
setSettingsInitialTab(tab);
|
||||
setShowSettings(true);
|
||||
}, []);
|
||||
|
||||
// Handle URL-based session loading
|
||||
useEffect(() => {
|
||||
if (sessionId && projects.length > 0) {
|
||||
@@ -236,16 +327,26 @@ function AppContent() {
|
||||
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
||||
// Find the session across all projects
|
||||
for (const project of projects) {
|
||||
const session = project.sessions?.find(s => s.id === sessionId);
|
||||
let session = project.sessions?.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
setSelectedProject(project);
|
||||
setSelectedSession(session);
|
||||
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
|
||||
@@ -270,8 +371,25 @@ function AppContent() {
|
||||
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) {
|
||||
setSidebarOpen(false);
|
||||
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}`);
|
||||
};
|
||||
@@ -374,14 +492,14 @@ function AppContent() {
|
||||
|
||||
// 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) => {
|
||||
const markSessionAsActive = useCallback((sessionId) => {
|
||||
if (sessionId) {
|
||||
setActiveSessions(prev => new Set([...prev, sessionId]));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates
|
||||
const markSessionAsInactive = (sessionId) => {
|
||||
const markSessionAsInactive = useCallback((sessionId) => {
|
||||
if (sessionId) {
|
||||
setActiveSessions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -389,12 +507,32 @@ function AppContent() {
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Processing Session Functions: Track which sessions are currently thinking/processing
|
||||
|
||||
// markSessionAsProcessing: Called when Claude starts thinking/processing
|
||||
const markSessionAsProcessing = useCallback((sessionId) => {
|
||||
if (sessionId) {
|
||||
setProcessingSessions(prev => new Set([...prev, sessionId]));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// markSessionAsNotProcessing: Called when Claude finishes thinking/processing
|
||||
const markSessionAsNotProcessing = useCallback((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) => {
|
||||
const replaceTemporarySession = useCallback((realSessionId) => {
|
||||
if (realSessionId) {
|
||||
setActiveSessions(prev => {
|
||||
const newSet = new Set();
|
||||
@@ -408,22 +546,75 @@ function AppContent() {
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Version Upgrade Modal Component
|
||||
const VersionUpgradeModal = () => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateOutput, setUpdateOutput] = useState('');
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
|
||||
if (!showVersionModal) return null;
|
||||
|
||||
// Clean up changelog by removing GitHub-specific metadata
|
||||
const cleanChangelog = (body) => {
|
||||
if (!body) return '';
|
||||
|
||||
return body
|
||||
// Remove full commit hashes (40 character hex strings)
|
||||
.replace(/\b[0-9a-f]{40}\b/gi, '')
|
||||
// Remove short commit hashes (7-10 character hex strings at start of line or after dash/space)
|
||||
.replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '')
|
||||
// Remove "Full Changelog" links
|
||||
.replace(/\*\*Full Changelog\*\*:.*$/gim, '')
|
||||
// Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1)
|
||||
.replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '')
|
||||
// Clean up multiple consecutive empty lines
|
||||
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||
// Trim whitespace
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
setIsUpdating(true);
|
||||
setUpdateOutput('Starting update...\n');
|
||||
setUpdateError('');
|
||||
|
||||
try {
|
||||
// Call the backend API to run the update command
|
||||
const response = await authenticatedFetch('/api/system/update', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setUpdateOutput(prev => prev + data.output + '\n');
|
||||
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
||||
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
|
||||
} else {
|
||||
setUpdateError(data.error || 'Update failed');
|
||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
||||
}
|
||||
} catch (error) {
|
||||
setUpdateError(error.message);
|
||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
<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">
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -434,7 +625,9 @@ function AppContent() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">A new version is ready</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{releaseInfo?.title || 'A new version is ready'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -459,18 +652,57 @@ function AppContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Instructions */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">How to upgrade:</h3>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
||||
git checkout main && git pull && npm install
|
||||
</code>
|
||||
{/* Changelog */}
|
||||
{releaseInfo?.body && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
|
||||
{releaseInfo?.htmlUrl && (
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
|
||||
>
|
||||
View full release
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600 max-h-64 overflow-y-auto">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none">
|
||||
{cleanChangelog(releaseInfo.body)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Run this command in your Claude Code UI directory to update to the latest version.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update Output */}
|
||||
{updateOutput && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade Instructions */}
|
||||
{!isUpdating && !updateOutput && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
||||
git checkout main && git pull && npm install
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Or click "Update Now" to run the update automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
@@ -478,18 +710,34 @@ function AppContent() {
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Copy command to clipboard
|
||||
navigator.clipboard.writeText('git checkout main && git pull && npm install');
|
||||
setShowVersionModal(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
Copy Command
|
||||
{updateOutput ? 'Close' : 'Later'}
|
||||
</button>
|
||||
{!updateOutput && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText('git checkout main && git pull && npm install');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
Copy Command
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateNow}
|
||||
disabled={isUpdating}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed rounded-md transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
'Update Now'
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,25 +748,78 @@ function AppContent() {
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
{/* Fixed Desktop Sidebar */}
|
||||
{!isMobile && (
|
||||
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
|
||||
<div
|
||||
className={`h-full flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
|
||||
sidebarVisible ? 'w-80' : 'w-14'
|
||||
}`}
|
||||
>
|
||||
<div className="h-full overflow-hidden">
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewSession={handleNewSession}
|
||||
onSessionDelete={handleSessionDelete}
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
/>
|
||||
{sidebarVisible ? (
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewSession={handleNewSession}
|
||||
onSessionDelete={handleSessionDelete}
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
releaseInfo={releaseInfo}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
onToggleSidebar={() => setSidebarVisible(false)}
|
||||
/>
|
||||
) : (
|
||||
/* Collapsed Sidebar */
|
||||
<div className="h-full flex flex-col items-center py-4 gap-4">
|
||||
{/* Expand Button */}
|
||||
<button
|
||||
onClick={() => setSidebarVisible(true)}
|
||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
||||
aria-label="Show sidebar"
|
||||
title="Show sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings Icon */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Update Indicator */}
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={() => setShowVersionModal(true)}
|
||||
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||
aria-label="Update available"
|
||||
title="Update available"
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-blue-500" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -528,7 +829,7 @@ function AppContent() {
|
||||
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
|
||||
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
|
||||
}`}>
|
||||
<div
|
||||
<button
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -539,9 +840,10 @@ function AppContent() {
|
||||
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 h-full transform transition-transform duration-150 ease-out ${
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -558,18 +860,22 @@ function AppContent() {
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
releaseInfo={releaseInfo}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
onToggleSidebar={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - Flexible */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
@@ -579,18 +885,24 @@ function AppContent() {
|
||||
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={() => setShowToolsSettings(true)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -608,34 +920,25 @@ function AppContent() {
|
||||
isOpen={showQuickSettings}
|
||||
onToggle={setShowQuickSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
onAutoExpandChange={(value) => {
|
||||
setAutoExpandTools(value);
|
||||
localStorage.setItem('autoExpandTools', JSON.stringify(value));
|
||||
}}
|
||||
onAutoExpandChange={setAutoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
onShowRawParametersChange={(value) => {
|
||||
setShowRawParameters(value);
|
||||
localStorage.setItem('showRawParameters', JSON.stringify(value));
|
||||
}}
|
||||
onShowRawParametersChange={setShowRawParameters}
|
||||
showThinking={showThinking}
|
||||
onShowThinkingChange={setShowThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
onAutoScrollChange={(value) => {
|
||||
setAutoScrollToBottom(value);
|
||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
||||
}}
|
||||
onAutoScrollChange={setAutoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onSendByCtrlEnterChange={(value) => {
|
||||
setSendByCtrlEnter(value);
|
||||
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
|
||||
}}
|
||||
onSendByCtrlEnterChange={setSendByCtrlEnter}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tools Settings Modal */}
|
||||
<ToolsSettings
|
||||
isOpen={showToolsSettings}
|
||||
onClose={() => setShowToolsSettings(false)}
|
||||
{/* Settings Modal */}
|
||||
<Settings
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
projects={projects}
|
||||
initialTab={settingsInitialTab}
|
||||
/>
|
||||
|
||||
{/* Version Upgrade Modal */}
|
||||
@@ -649,17 +952,23 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
<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;
|
||||
export default App;
|
||||
|
||||
371
src/components/ApiKeysSettings.jsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function ApiKeysSettings() {
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [githubTokens, setGithubTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
|
||||
const [showNewTokenForm, setShowNewTokenForm] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [newGithubToken, setNewGithubToken] = useState('');
|
||||
const [showToken, setShowToken] = useState({});
|
||||
const [copiedKey, setCopiedKey] = useState(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch API keys
|
||||
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
|
||||
const apiKeysData = await apiKeysRes.json();
|
||||
setApiKeys(apiKeysData.apiKeys || []);
|
||||
|
||||
// Fetch GitHub tokens
|
||||
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const githubData = await githubRes.json();
|
||||
setGithubTokens(githubData.credentials || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyName: newKeyName })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewlyCreatedKey(data.apiKey);
|
||||
setNewKeyName('');
|
||||
setShowNewKeyForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('Are you sure you want to delete this API key?')) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApiKey = async (keyId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createGithubToken = async () => {
|
||||
if (!newTokenName.trim() || !newGithubToken.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/credentials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialName: newTokenName,
|
||||
credentialType: 'github_token',
|
||||
credentialValue: newGithubToken
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewTokenName('');
|
||||
setNewGithubToken('');
|
||||
setShowNewTokenForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating GitHub token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGithubToken = async (tokenId) => {
|
||||
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting GitHub token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGithubToken = async (tokenId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling GitHub token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, id) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(id);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* New API Key Alert */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">⚠️ Save Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
This is the only time you'll see this key. Store it securely.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
|
||||
{newlyCreatedKey.apiKey}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
|
||||
>
|
||||
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-3"
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
>
|
||||
I've saved it
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New API Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Generate API keys to access the external API from other applications.
|
||||
</p>
|
||||
|
||||
{showNewKeyForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder="API Key Name (e.g., Production Server)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createApiKey}>Create</Button>
|
||||
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{key.key_name}</div>
|
||||
<code className="text-xs text-muted-foreground">{key.api_key}</code>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={key.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleApiKey(key.id, key.is_active)}
|
||||
>
|
||||
{key.is_active ? 'Active' : 'Inactive'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Tokens Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">GitHub Tokens</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add GitHub Personal Access Tokens to clone private repositories via the external API.
|
||||
</p>
|
||||
|
||||
{showNewTokenForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder="Token Name (e.g., Personal Repos)"
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken['new'] ? 'text' : 'password'}
|
||||
placeholder="GitHub Personal Access Token (ghp_...)"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
className="mb-2 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createGithubToken}>Add Token</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowNewTokenForm(false);
|
||||
setNewTokenName('');
|
||||
setNewGithubToken('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{githubTokens.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
|
||||
) : (
|
||||
githubTokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{token.credential_name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Added: {new Date(token.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={token.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleGithubToken(token.id, token.is_active)}
|
||||
>
|
||||
{token.is_active ? 'Active' : 'Inactive'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteGithubToken(token.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation Link */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">External API Documentation</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Learn how to use the external API to trigger Claude/Cursor sessions from your applications.
|
||||
</p>
|
||||
<a
|
||||
href="/EXTERNAL_API.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View API Documentation →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeysSettings;
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
function ClaudeStatus({ status, onAbort, isLoading }) {
|
||||
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) {
|
||||
@@ -13,29 +13,34 @@ function ClaudeStatus({ status, onAbort, isLoading }) {
|
||||
setFakeTokens(0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const startTime = Date.now();
|
||||
// Calculate random token rate once (30-50 tokens per second)
|
||||
const tokenRate = 30 + Math.random() * 20;
|
||||
|
||||
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)));
|
||||
// Simulate token count increasing over time
|
||||
setFakeTokens(Math.floor(elapsed * tokenRate));
|
||||
}, 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
|
||||
@@ -52,46 +57,41 @@ function ClaudeStatus({ status, onAbort, isLoading }) {
|
||||
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">
|
||||
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-xl transition-all duration-500",
|
||||
"text-base sm:text-xl transition-all duration-500 flex-shrink-0",
|
||||
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>
|
||||
|
||||
{/* Status text - compact for mobile */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({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-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">⚒ {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
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
|
||||
</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"
|
||||
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
@@ -7,104 +7,252 @@ 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 { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
|
||||
import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath }) {
|
||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const savedTheme = localStorage.getItem('codeEditorTheme');
|
||||
return savedTheme ? savedTheme === 'dark' : true;
|
||||
});
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
|
||||
// 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 [wordWrap, setWordWrap] = useState(() => {
|
||||
return localStorage.getItem('codeEditorWordWrap') === 'true';
|
||||
});
|
||||
|
||||
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'
|
||||
}
|
||||
const [minimapEnabled, setMinimapEnabled] = useState(() => {
|
||||
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
|
||||
});
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(() => {
|
||||
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
return localStorage.getItem('codeEditorFontSize') || '14';
|
||||
});
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// Create minimap extension with chunk-based gutters
|
||||
const minimapExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
|
||||
|
||||
const gutters = {};
|
||||
|
||||
return [
|
||||
showMinimap.compute(['doc'], (state) => {
|
||||
// Get actual chunks from merge view
|
||||
const chunksData = getChunks(state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
// Clear previous gutters
|
||||
Object.keys(gutters).forEach(key => delete gutters[key]);
|
||||
|
||||
// Mark lines that are part of chunks
|
||||
chunks.forEach(chunk => {
|
||||
// Mark the lines in the B side (current document)
|
||||
const fromLine = state.doc.lineAt(chunk.fromB).number;
|
||||
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
|
||||
|
||||
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
|
||||
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
create: () => ({ dom: document.createElement('div') }),
|
||||
displayText: 'blocks',
|
||||
showOverlay: 'always',
|
||||
gutters: [gutters]
|
||||
};
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
|
||||
|
||||
// Create extension to scroll to first chunk on mount
|
||||
const scrollToFirstChunkExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
|
||||
return [
|
||||
ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
// Delay to ensure merge view is fully initialized
|
||||
setTimeout(() => {
|
||||
const chunksData = getChunks(view.state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const firstChunk = chunks[0];
|
||||
|
||||
// Scroll to the first chunk
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
update() {}
|
||||
destroy() {}
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
|
||||
// Create editor toolbar panel - always visible
|
||||
const editorToolbarPanel = useMemo(() => {
|
||||
const createPanel = (view) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-editor-toolbar-panel';
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
const updatePanel = () => {
|
||||
// Check if we have diff info and it's enabled
|
||||
const hasDiff = file.diffInfo && showDiff;
|
||||
const chunksData = hasDiff ? getChunks(view.state) : null;
|
||||
const chunks = chunksData?.chunks || [];
|
||||
const chunkCount = chunks.length;
|
||||
|
||||
// Build the toolbar HTML
|
||||
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
|
||||
|
||||
// Left side - diff navigation (if applicable)
|
||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
|
||||
if (hasDiff) {
|
||||
toolbarHTML += `
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
toolbarHTML += '</div>';
|
||||
|
||||
// Right side - action buttons
|
||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
|
||||
|
||||
// Show/hide diff button (only if there's diff info)
|
||||
if (file.diffInfo) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? 'Hide diff highlighting' : 'Show diff highlighting'}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${showDiff ?
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Settings button
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-settings-btn" title="Editor Settings">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Expand button (only in sidebar mode)
|
||||
if (isSidebar && onToggleExpand) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? 'Collapse editor' : 'Expand editor to full width'}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${isExpanded ?
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
toolbarHTML += '</div>';
|
||||
toolbarHTML += '</div>';
|
||||
|
||||
dom.innerHTML = toolbarHTML;
|
||||
|
||||
// Attach event listeners for diff navigation
|
||||
if (hasDiff) {
|
||||
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
|
||||
const nextBtn = dom.querySelector('.cm-diff-nav-next');
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
|
||||
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
|
||||
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for toggle diff button
|
||||
if (file.diffInfo) {
|
||||
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
|
||||
toggleDiffBtn?.addEventListener('click', () => {
|
||||
setShowDiff(!showDiff);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for settings button
|
||||
const settingsBtn = dom.querySelector('.cm-settings-btn');
|
||||
settingsBtn?.addEventListener('click', () => {
|
||||
if (window.openSettings) {
|
||||
window.openSettings('appearance');
|
||||
}
|
||||
});
|
||||
|
||||
// Attach event listener for expand button
|
||||
if (isSidebar && onToggleExpand) {
|
||||
const expandBtn = dom.querySelector('.cm-expand-btn');
|
||||
expandBtn?.addEventListener('click', () => {
|
||||
onToggleExpand();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updatePanel();
|
||||
|
||||
return {
|
||||
top: true,
|
||||
dom,
|
||||
update: updatePanel
|
||||
};
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand]);
|
||||
|
||||
// Get language extension based on file extension
|
||||
const getLanguageExtension = (filename) => {
|
||||
@@ -139,13 +287,24 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// If we have diffInfo with both old and new content, we can show the diff directly
|
||||
// This handles both GitPanel (full content) and ChatInterface (full content from API)
|
||||
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
|
||||
// Use the new_string as the content to display
|
||||
// The unifiedMergeView will compare it against old_string
|
||||
setContent(file.diffInfo.new_string);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, load from disk
|
||||
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) {
|
||||
@@ -159,37 +318,41 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
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 {
|
||||
console.log('Saving file:', {
|
||||
projectName: file.projectName,
|
||||
path: file.path,
|
||||
contentLength: content?.length
|
||||
});
|
||||
|
||||
const response = await api.saveFile(file.projectName, file.path, content);
|
||||
|
||||
console.log('Save response:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
contentType: response.headers.get('content-type')
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
} else {
|
||||
const textError = await response.text();
|
||||
console.error('Non-JSON error response:', textError);
|
||||
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success feedback
|
||||
console.log('Save successful:', result);
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
|
||||
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
alert(`Error saving file: ${error.message}`);
|
||||
@@ -214,6 +377,57 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// Save theme preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Save word wrap preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
|
||||
}, [wordWrap]);
|
||||
|
||||
// Listen for settings changes from the Settings modal
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
const newTheme = localStorage.getItem('codeEditorTheme');
|
||||
if (newTheme) {
|
||||
setIsDarkMode(newTheme === 'dark');
|
||||
}
|
||||
|
||||
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
|
||||
if (newWordWrap !== null) {
|
||||
setWordWrap(newWordWrap === 'true');
|
||||
}
|
||||
|
||||
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
|
||||
if (newShowMinimap !== null) {
|
||||
setMinimapEnabled(newShowMinimap !== 'false');
|
||||
}
|
||||
|
||||
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
|
||||
if (newShowLineNumbers !== null) {
|
||||
setShowLineNumbers(newShowLineNumbers !== 'false');
|
||||
}
|
||||
|
||||
const newFontSize = localStorage.getItem('codeEditorFontSize');
|
||||
if (newFontSize) {
|
||||
setFontSize(newFontSize);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for storage events (changes from other tabs/windows)
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Custom event for same-window updates
|
||||
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -245,80 +459,130 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
{isSidebar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-40 md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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]')
|
||||
}`}>
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
/* Light background for full line changes */
|
||||
.cm-deletedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.cm-insertedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
/* Override linear-gradient underline and use solid darker background for partial changes */
|
||||
.cm-editor.cm-merge-b .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-deletedChunk .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
/* Minimap gutter styling */
|
||||
.cm-gutter.cm-gutter-minimap {
|
||||
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
|
||||
}
|
||||
|
||||
/* Editor toolbar panel styling */
|
||||
.cm-editor-toolbar-panel {
|
||||
padding: 8px 12px;
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
|
||||
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
|
||||
color: ${isDarkMode ? '#d1d5db' : '#374151'};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn,
|
||||
.cm-toolbar-btn {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:hover,
|
||||
.cm-toolbar-btn:hover {
|
||||
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={isSidebar ?
|
||||
'w-full h-full flex flex-col' :
|
||||
`fixed inset-0 z-40 ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={isSidebar ?
|
||||
'bg-background flex flex-col w-full h-full' :
|
||||
`bg-background shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-sm font-mono">
|
||||
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
📝 Has changes
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
Showing changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">{file.path}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
{file.diffInfo && (
|
||||
<button
|
||||
onClick={() => setShowDiff(!showDiff)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={showDiff ? "Hide diff highlighting" : "Show diff highlighting"}
|
||||
>
|
||||
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
|
||||
wordWrap
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<span className="text-sm md:text-xs font-mono font-bold">↵</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
@@ -326,13 +590,13 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
>
|
||||
<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'
|
||||
saveSuccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
@@ -350,15 +614,17 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
|
||||
{!isSidebar && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
@@ -377,18 +643,33 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
diffField,
|
||||
diffTheme,
|
||||
// Always show the toolbar
|
||||
...editorToolbarPanel,
|
||||
// Only show diff-related extensions when diff is enabled
|
||||
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
|
||||
? [
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true
|
||||
// NOTE: NO collapseUnchanged - this shows the full file!
|
||||
}),
|
||||
...minimapExtension,
|
||||
...scrollToFirstChunkExtension
|
||||
]
|
||||
: []),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
@@ -403,20 +684,20 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</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 justify-between p-3 border-t border-border bg-muted 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;
|
||||
export default CodeEditor;
|
||||
|
||||
16
src/components/CodexLogo.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const CodexLogo = ({ className = 'w-5 h-5' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/codex-white.svg" : "/icons/codex.svg"}
|
||||
alt="Codex"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexLogo;
|
||||
344
src/components/CommandMenu.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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;
|
||||
88
src/components/CreateTaskModal.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { X, Sparkles } from 'lucide-react';
|
||||
|
||||
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
|
||||
{/* 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">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create AI-Generated Task</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"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* AI-First Approach */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
💡 Pro Tip: Ask Claude Code Directly!
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
You can simply ask Claude Code in the chat to create tasks for you.
|
||||
The AI assistant will automatically generate detailed tasks with research-backed insights.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3 mb-3">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example:</p>
|
||||
<p className="text-sm 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>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>This runs:</strong> <code className="bg-blue-100 dark:bg-blue-900/50 px-1 rounded text-xs">
|
||||
task-master add-task --prompt="Implement user profile image uploads using Cloudinary" --research
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More Link */}
|
||||
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
For more examples and advanced usage patterns:
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-medium"
|
||||
>
|
||||
View TaskMaster Documentation →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Got it, I'll ask Claude Code directly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
419
src/components/CredentialsSettings.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
|
||||
import { useVersionCheck } from '../hooks/useVersionCheck';
|
||||
import { version } from '../../package.json';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function CredentialsSettings() {
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [githubCredentials, setGithubCredentials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
|
||||
const [showNewGithubForm, setShowNewGithubForm] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newGithubName, setNewGithubName] = useState('');
|
||||
const [newGithubToken, setNewGithubToken] = useState('');
|
||||
const [newGithubDescription, setNewGithubDescription] = useState('');
|
||||
const [showToken, setShowToken] = useState({});
|
||||
const [copiedKey, setCopiedKey] = useState(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
|
||||
|
||||
// Version check hook
|
||||
const { updateAvailable, latestVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch API keys
|
||||
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
|
||||
const apiKeysData = await apiKeysRes.json();
|
||||
setApiKeys(apiKeysData.apiKeys || []);
|
||||
|
||||
// Fetch GitHub credentials only
|
||||
const credentialsRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const credentialsData = await credentialsRes.json();
|
||||
setGithubCredentials(credentialsData.credentials || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyName: newKeyName })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewlyCreatedKey(data.apiKey);
|
||||
setNewKeyName('');
|
||||
setShowNewKeyForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('Are you sure you want to delete this API key?')) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApiKey = async (keyId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createGithubCredential = async () => {
|
||||
if (!newGithubName.trim() || !newGithubToken.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/credentials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialName: newGithubName,
|
||||
credentialType: 'github_token',
|
||||
credentialValue: newGithubToken,
|
||||
description: newGithubDescription
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewGithubName('');
|
||||
setNewGithubToken('');
|
||||
setNewGithubDescription('');
|
||||
setShowNewGithubForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating GitHub credential:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGithubCredential = async (credentialId) => {
|
||||
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting GitHub credential:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGithubCredential = async (credentialId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling GitHub credential:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, id) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(id);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* New API Key Alert */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">⚠️ Save Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
This is the only time you'll see this key. Store it securely.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
|
||||
{newlyCreatedKey.apiKey}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
|
||||
>
|
||||
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-3"
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
>
|
||||
I've saved it
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New API Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Generate API keys to access the external API from other applications.
|
||||
</p>
|
||||
<a
|
||||
href="/api-docs.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
API Documentation
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{showNewKeyForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder="API Key Name (e.g., Production Server)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createApiKey}>Create</Button>
|
||||
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{key.key_name}</div>
|
||||
<code className="text-xs text-muted-foreground">{key.api_key}</code>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={key.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleApiKey(key.id, key.is_active)}
|
||||
>
|
||||
{key.is_active ? 'Active' : 'Inactive'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Credentials Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">GitHub Credentials</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.
|
||||
</p>
|
||||
|
||||
{showNewGithubForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
|
||||
<Input
|
||||
placeholder="Token Name (e.g., Personal Repos)"
|
||||
value={newGithubName}
|
||||
onChange={(e) => setNewGithubName(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken['new'] ? 'text' : 'password'}
|
||||
placeholder="GitHub Personal Access Token (ghp_...)"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="Description (optional)"
|
||||
value={newGithubDescription}
|
||||
onChange={(e) => setNewGithubDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createGithubCredential}>Add Token</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowNewGithubForm(false);
|
||||
setNewGithubName('');
|
||||
setNewGithubToken('');
|
||||
setNewGithubDescription('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline block"
|
||||
>
|
||||
How to create a GitHub Personal Access Token →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{githubCredentials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
|
||||
) : (
|
||||
githubCredentials.map((credential) => (
|
||||
<div
|
||||
key={credential.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{credential.credential_name}</div>
|
||||
{credential.description && (
|
||||
<div className="text-xs text-muted-foreground">{credential.description}</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Added: {new Date(credential.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={credential.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
|
||||
>
|
||||
{credential.is_active ? 'Active' : 'Inactive'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteGithubCredential(credential.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Information */}
|
||||
<div className="pt-6 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
|
||||
<a
|
||||
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
{updateAvailable && latestVersion && (
|
||||
<a
|
||||
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
|
||||
>
|
||||
<span className="text-[10px]">Update available: v{latestVersion}</span>
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CredentialsSettings;
|
||||
16
src/components/CursorLogo.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/cursor-white.svg" : "/icons/cursor.svg"}
|
||||
alt="Cursor"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorLogo;
|
||||
41
src/components/DiffViewer.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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">
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs p-2 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="diff-viewer">
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiffViewer;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye } from 'lucide-react';
|
||||
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';
|
||||
@@ -14,6 +15,8 @@ function FileTree({ selectedProject }) {
|
||||
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) {
|
||||
@@ -29,6 +32,51 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 {
|
||||
@@ -308,42 +356,67 @@ function FileTree({ selectedProject }) {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border 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>
|
||||
{/* 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' && files.length > 0 && (
|
||||
{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>
|
||||
@@ -365,11 +438,21 @@ function FileTree({ selectedProject }) {
|
||||
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(files)}
|
||||
{viewMode === 'compact' && renderCompactView(files)}
|
||||
{viewMode === 'detailed' && renderDetailedView(files)}
|
||||
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import DiffViewer from './DiffViewer.jsx';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
const [gitDiff, setGitDiff] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -31,9 +32,26 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
|
||||
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
|
||||
const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Get current provider from localStorage (same as ChatInterface does)
|
||||
const [provider, setProvider] = useState(() => {
|
||||
return localStorage.getItem('selected-provider') || 'claude';
|
||||
});
|
||||
|
||||
// Listen for provider changes in localStorage
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
const newProvider = localStorage.getItem('selected-provider') || 'claude';
|
||||
setProvider(newProvider);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchGitStatus();
|
||||
@@ -60,14 +78,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const fetchGitStatus = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
console.log('Fetching git status for project:', selectedProject.name, 'path:', selectedProject.path);
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Git status response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('Git status error:', data.error);
|
||||
@@ -92,6 +108,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
for (const file of data.added || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
for (const file of data.deleted || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
for (const file of data.untracked || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching git status:', error);
|
||||
@@ -387,7 +409,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
setGitDiff(prev => ({
|
||||
...prev,
|
||||
@@ -399,6 +421,36 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileOpen = async (filePath) => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch file content with diff information
|
||||
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error fetching file with diff:', data.error);
|
||||
// Fallback: open without diff info
|
||||
onFileOpen(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create diffInfo object for CodeEditor
|
||||
const diffInfo = {
|
||||
old_string: data.oldContent || '',
|
||||
new_string: data.currentContent || ''
|
||||
};
|
||||
|
||||
// Open file with diff information
|
||||
onFileOpen(filePath, diffInfo);
|
||||
} catch (error) {
|
||||
console.error('Error opening file:', error);
|
||||
// Fallback: open without diff info
|
||||
onFileOpen(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecentCommits = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
@@ -436,10 +488,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
files: Array.from(selectedFiles)
|
||||
files: Array.from(selectedFiles),
|
||||
provider: provider // Pass the current provider (claude or cursor)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.message) {
|
||||
setCommitMessage(data.message);
|
||||
@@ -495,7 +548,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!commitMessage.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
|
||||
setIsCommitting(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/commit', {
|
||||
@@ -507,7 +560,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
files: Array.from(selectedFiles)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Reset state after successful commit
|
||||
@@ -525,26 +578,31 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
const createInitialCommit = async () => {
|
||||
setIsCreatingInitialCommit(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/initial-commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Initial commit failed:', data.error);
|
||||
alert(data.error || 'Failed to create initial commit');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating initial commit:', error);
|
||||
alert('Failed to create initial commit');
|
||||
} finally {
|
||||
setIsCreatingInitialCommit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
@@ -592,7 +650,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||
{commit.stats}
|
||||
</div>
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -615,14 +673,28 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`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 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center flex-1 cursor-pointer"
|
||||
onClick={() => toggleFileExpanded(filePath)}
|
||||
<div
|
||||
className="flex items-center flex-1"
|
||||
>
|
||||
<div className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded ${isMobile ? 'mr-1' : 'mr-2'}`}>
|
||||
<div
|
||||
className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFileExpanded(filePath);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
|
||||
</div>
|
||||
<span className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'}`}>{filePath}</span>
|
||||
<span
|
||||
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 hover:underline`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFileOpen(filePath);
|
||||
}}
|
||||
title="Click to open file"
|
||||
>
|
||||
{filePath}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{(status === 'M' || status === 'D') && (
|
||||
<button
|
||||
@@ -707,8 +779,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{diff && <DiffViewer diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -724,7 +796,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
@@ -1111,11 +1183,36 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
{/* File List - Changes View - Only show when git is available */}
|
||||
{activeView === 'changes' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : gitStatus?.hasCommits === false ? (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<GitBranch className="w-16 h-16 mb-4 opacity-30 text-gray-400 dark:text-gray-500" />
|
||||
<h3 className="text-lg font-medium mb-2 text-gray-900 dark:text-white">No commits yet</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md">
|
||||
This repository doesn't have any commits yet. Create your first commit to start tracking changes.
|
||||
</p>
|
||||
<button
|
||||
onClick={createInitialCommit}
|
||||
disabled={isCreatingInitialCommit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isCreatingInitialCommit ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<span>Creating Initial Commit...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCommit className="w-4 h-4" />
|
||||
<span>Create Initial Commit</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<GitCommit className="w-12 h-12 mb-2 opacity-50" />
|
||||
@@ -1134,7 +1231,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
{/* History View - Only show when git is available */}
|
||||
{activeView === 'history' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
|
||||
129
src/components/GitSettings.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { GitBranch, Check } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function GitSettings() {
|
||||
const [gitName, setGitName] = useState('');
|
||||
const [gitEmail, setGitEmail] = useState('');
|
||||
const [gitConfigLoading, setGitConfigLoading] = useState(false);
|
||||
const [gitConfigSaving, setGitConfigSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadGitConfig();
|
||||
}, []);
|
||||
|
||||
const loadGitConfig = async () => {
|
||||
try {
|
||||
setGitConfigLoading(true);
|
||||
const response = await authenticatedFetch('/api/user/git-config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGitName(data.gitName || '');
|
||||
setGitEmail(data.gitEmail || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading git config:', error);
|
||||
} finally {
|
||||
setGitConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitConfig = async () => {
|
||||
try {
|
||||
setGitConfigSaving(true);
|
||||
const response = await authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gitName, gitEmail })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setSaveStatus('error');
|
||||
console.error('Failed to save git config:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving git config:', error);
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setGitConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Git Configuration</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure your git identity for commits. These settings will be applied globally via <code className="bg-muted px-2 py-0.5 rounded text-xs">git config --global</code>
|
||||
</p>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-card space-y-3">
|
||||
<div>
|
||||
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
|
||||
Git Name
|
||||
</label>
|
||||
<Input
|
||||
id="settings-git-name"
|
||||
type="text"
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
disabled={gitConfigLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Your name for git commits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
|
||||
Git Email
|
||||
</label>
|
||||
<Input
|
||||
id="settings-git-email"
|
||||
type="email"
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
disabled={gitConfigLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Your email for git commits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={saveGitConfig}
|
||||
disabled={gitConfigSaving || !gitName || !gitEmail}
|
||||
>
|
||||
{gitConfigSaving ? 'Saving...' : 'Save Configuration'}
|
||||
</Button>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||
<Check className="w-4 h-4" />
|
||||
Saved successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitSettings;
|
||||
@@ -1,9 +1,55 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function ImageViewer({ file, onClose }) {
|
||||
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setImageUrl(null);
|
||||
|
||||
const response = await authenticatedFetch(imagePath, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error loading image:', err);
|
||||
setError('Unable to load image');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [imagePath]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -23,22 +69,24 @@ function ImageViewer({ file, onClose }) {
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
|
||||
<img
|
||||
src={imagePath}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-center text-gray-500 dark:text-gray-400"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<p>Unable to load image</p>
|
||||
<p className="text-sm mt-2">{file.path}</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>Loading image…</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
||||
/>
|
||||
)}
|
||||
{!loading && !imageUrl && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>{error || 'Unable to load image'}</p>
|
||||
<p className="text-sm mt-2 break-all">{file.path}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
||||
@@ -51,4 +99,4 @@ function ImageViewer({ file, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageViewer;
|
||||
export default ImageViewer;
|
||||
|
||||
92
src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function LoginModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider = 'claude',
|
||||
project,
|
||||
onComplete,
|
||||
customCommand
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCommand = () => {
|
||||
if (customCommand) return customCommand;
|
||||
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return 'claude setup-token --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return 'claude setup-token --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);
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -11,39 +11,112 @@
|
||||
* No session protection logic is implemented here - it's purely a props bridge.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import Shell from './Shell';
|
||||
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,
|
||||
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
|
||||
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
|
||||
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);
|
||||
const [editorWidth, setEditorWidth] = useState(600);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const resizeRef = useRef(null);
|
||||
|
||||
// PRD Editor state
|
||||
const [showPRDEditor, setShowPRDEditor] = useState(false);
|
||||
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
|
||||
@@ -58,16 +131,94 @@ function MainContent({
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setEditingFile(null);
|
||||
setEditorExpanded(false);
|
||||
};
|
||||
|
||||
const handleToggleEditorExpand = () => {
|
||||
setEditorExpanded(!editorExpanded);
|
||||
};
|
||||
|
||||
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?.();
|
||||
};
|
||||
|
||||
// Handle resize functionality
|
||||
const handleMouseDown = (e) => {
|
||||
if (isMobile) return; // Disable resize on mobile
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const container = resizeRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - e.clientX;
|
||||
|
||||
// Min width: 300px, Max width: 80% of container
|
||||
const minWidth = 300;
|
||||
const maxWidth = containerRect.width * 0.8;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setEditorWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 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 flex-shrink-0">
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 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"
|
||||
className="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-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" />
|
||||
@@ -100,10 +251,12 @@ function MainContent({
|
||||
<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 flex-shrink-0">
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 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"
|
||||
className="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-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" />
|
||||
@@ -136,9 +289,11 @@ function MainContent({
|
||||
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 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-between relative">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
@@ -146,108 +301,149 @@ function MainContent({
|
||||
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"
|
||||
className="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-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 className="min-w-0">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{selectedSession.summary}
|
||||
</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' : 'Project'}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
|
||||
{activeTab === 'chat' && selectedSession && (
|
||||
<div className="w-5 h-5 flex-shrink-0 flex items-center justify-center">
|
||||
{selectedSession.__provider === 'cursor' ? (
|
||||
<CursorLogo className="w-4 h-4" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white whitespace-nowrap overflow-x-auto scrollbar-hide">
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'chat' && !selectedSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base 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 className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base 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">
|
||||
<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 sm:inline">Chat</span>
|
||||
</span>
|
||||
</button>
|
||||
<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 sm:inline">Shell</span>
|
||||
</span>
|
||||
</button>
|
||||
<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 sm:inline">Files</span>
|
||||
</span>
|
||||
</button>
|
||||
<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 sm:inline">Source Control</span>
|
||||
</span>
|
||||
</button>
|
||||
<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 ${
|
||||
@@ -268,11 +464,13 @@ function MainContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
{/* Content Area with Right Sidebar */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''} ${editorExpanded ? 'hidden' : ''}`}>
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
@@ -282,29 +480,75 @@ function MainContent({
|
||||
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'}`}>
|
||||
<Shell
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
isActive={activeTab === 'shell'}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} />
|
||||
</div>
|
||||
{activeTab === 'files' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'shell' && (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'git' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</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}
|
||||
@@ -331,16 +575,108 @@ function MainContent({
|
||||
onClearLogs={() => setServerLogs([])}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
|
||||
{editingFile && !isMobile && (
|
||||
<>
|
||||
{/* Resize Handle - Hidden when expanded */}
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
{/* Visual indicator on hover */}
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Sidebar */}
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
|
||||
style={editorExpanded ? {} : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
isSidebar={true}
|
||||
isExpanded={editorExpanded}
|
||||
onToggleExpand={handleToggleEditorExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
{editingFile && (
|
||||
{/* Code Editor Modal for Mobile */}
|
||||
{editingFile && isMobile && (
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
isSidebar={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe } from 'lucide-react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe, CheckSquare } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
// Detect dark mode
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
const navItems = [
|
||||
{
|
||||
id: 'chat',
|
||||
@@ -24,26 +24,21 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
id: 'git',
|
||||
icon: GitBranch,
|
||||
onClick: () => setActiveTab('git')
|
||||
}
|
||||
},
|
||||
// Conditionally add tasks tab if enabled
|
||||
...(tasksEnabled ? [{
|
||||
id: 'tasks',
|
||||
icon: CheckSquare,
|
||||
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={`fixed bottom-0 left-0 right-0 bg-background border-t border-border 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;
|
||||
@@ -73,7 +68,6 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
695
src/components/NextTaskBanner.jsx
Normal file
@@ -0,0 +1,695 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause, ChevronDown, ChevronUp, Plus, FileText, Settings, X, Terminal, Eye, Play, Zap, Target } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { api } from '../utils/api';
|
||||
import Shell from './Shell';
|
||||
import TaskDetail from './TaskDetail';
|
||||
|
||||
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
|
||||
const { nextTask, tasks, currentProject, isLoadingTasks, projectTaskMaster, refreshTasks, refreshProjects } = useTaskMaster();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTaskOptions, setShowTaskOptions] = useState(false);
|
||||
const [showCreateTaskModal, setShowCreateTaskModal] = useState(false);
|
||||
const [showTemplateSelector, setShowTemplateSelector] = useState(false);
|
||||
const [showCLI, setShowCLI] = useState(false);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Handler functions
|
||||
const handleInitializeTaskMaster = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.taskmaster.init(currentProject.name);
|
||||
if (response.ok) {
|
||||
await refreshProjects();
|
||||
setShowTaskOptions(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to initialize TaskMaster:', error);
|
||||
alert(`Failed to initialize TaskMaster: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing TaskMaster:', error);
|
||||
alert('Error initializing TaskMaster. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateManualTask = () => {
|
||||
setShowCreateTaskModal(true);
|
||||
setShowTaskOptions(false);
|
||||
};
|
||||
|
||||
const handleParsePRD = () => {
|
||||
setShowTemplateSelector(true);
|
||||
setShowTaskOptions(false);
|
||||
};
|
||||
|
||||
// Don't show if no project or still loading
|
||||
if (!currentProject || isLoadingTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bannerContent;
|
||||
|
||||
// Show setup message only if no tasks exist AND TaskMaster is not configured
|
||||
if ((!tasks || tasks.length === 0) && !projectTaskMaster?.hasTaskmaster) {
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
TaskMaster AI is not configured
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowTaskOptions(!showTaskOptions)}
|
||||
className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
Initialize TaskMaster AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTaskOptions && (
|
||||
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||
{!projectTaskMaster?.hasTaskmaster && (
|
||||
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
🎯 What is TaskMaster?
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{!projectTaskMaster?.hasTaskmaster ? (
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 rounded transition-colors text-left flex items-center gap-2"
|
||||
onClick={() => setShowCLI(true)}
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Initialize TaskMaster
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 p-2 bg-green-50 dark:bg-green-900/30 rounded text-xs text-green-800 dark:text-green-200">
|
||||
<strong>Add more tasks:</strong> Create additional tasks manually or generate them from a PRD template
|
||||
</div>
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||
onClick={handleCreateManualTask}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Create a new task manually
|
||||
</button>
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-purple-100 dark:bg-purple-900 hover:bg-purple-200 dark:hover:bg-purple-800 text-purple-800 dark:text-purple-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||
onClick={handleParsePRD}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{isLoading ? 'Parsing...' : 'Generate tasks from PRD template'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (nextTask) {
|
||||
// Show next task if available
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Target className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Task {nextTask.id}</span>
|
||||
{nextTask.priority === 'high' && (
|
||||
<div className="w-4 h-4 rounded bg-red-100 dark:bg-red-900/50 flex items-center justify-center" title="High Priority">
|
||||
<Zap className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{nextTask.priority === 'medium' && (
|
||||
<div className="w-4 h-4 rounded bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center" title="Medium Priority">
|
||||
<Flag className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
)}
|
||||
{nextTask.priority === 'low' && (
|
||||
<div className="w-4 h-4 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center" title="Low Priority">
|
||||
<Circle className="w-2.5 h-2.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 line-clamp-1">
|
||||
{nextTask.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onStartTask?.()}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium transition-colors shadow-sm flex items-center gap-1"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
Start Task
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTaskDetail(true)}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
|
||||
title="View task details"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
|
||||
title="View all tasks"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
} else if (tasks && tasks.length > 0) {
|
||||
// Show completion message only if there are tasks and all are done
|
||||
const completedTasks = tasks.filter(task => task.status === 'done').length;
|
||||
const totalTasks = tasks.length;
|
||||
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{completedTasks === totalTasks ? "All done! 🎉" : "No pending tasks"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{completedTasks}/{totalTasks}
|
||||
</span>
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// TaskMaster is configured but no tasks exist - don't show anything in chat
|
||||
bannerContent = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{bannerContent}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateTaskModal && (
|
||||
<CreateTaskModal
|
||||
currentProject={currentProject}
|
||||
onClose={() => setShowCreateTaskModal(false)}
|
||||
onTaskCreated={() => {
|
||||
refreshTasks();
|
||||
setShowCreateTaskModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Selector Modal */}
|
||||
{showTemplateSelector && (
|
||||
<TemplateSelector
|
||||
currentProject={currentProject}
|
||||
onClose={() => setShowTemplateSelector(false)}
|
||||
onTemplateApplied={() => {
|
||||
refreshTasks();
|
||||
setShowTemplateSelector(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TaskMaster CLI Setup Modal */}
|
||||
{showCLI && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl h-[600px] flex flex-col">
|
||||
{/* Modal 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCLI(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-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Container */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full bg-black rounded-lg overflow-hidden">
|
||||
<Shell
|
||||
selectedProject={currentProject}
|
||||
selectedSession={null}
|
||||
isActive={true}
|
||||
initialCommand="npx task-master init"
|
||||
isPlainShell={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
TaskMaster initialization will start automatically
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCLI(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{showTaskDetail && nextTask && (
|
||||
<TaskDetail
|
||||
task={nextTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={() => setShowTaskDetail(false)}
|
||||
onStatusChange={() => refreshTasks?.()}
|
||||
onTaskClick={null} // Disable dependency navigation in NextTaskBanner for now
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple Create Task Modal Component
|
||||
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
useAI: false,
|
||||
prompt: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const taskData = formData.useAI
|
||||
? { prompt: formData.prompt, priority: formData.priority }
|
||||
: { title: formData.title, description: formData.description, priority: formData.priority };
|
||||
|
||||
const response = await api.taskmaster.addTask(currentProject.name, taskData);
|
||||
|
||||
if (response.ok) {
|
||||
onTaskCreated();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to create task:', error);
|
||||
alert(`Failed to create task: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
alert('Error creating task. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Task</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useAI}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, useAI: e.target.checked }))}
|
||||
/>
|
||||
Use AI to generate task details
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.useAI ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Task Description (AI will generate details)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, prompt: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
placeholder="Describe what you want to accomplish..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Task Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Enter task title..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
placeholder="Describe the task..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
|
||||
disabled={isSubmitting || (formData.useAI && !formData.prompt.trim()) || (!formData.useAI && (!formData.title.trim() || !formData.description.trim()))}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Task'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template Selector Modal Component
|
||||
const TemplateSelector = ({ currentProject, onClose, onTemplateApplied }) => {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [customizations, setCustomizations] = useState({});
|
||||
const [fileName, setFileName] = useState('prd.txt');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [step, setStep] = useState('select'); // 'select', 'customize', 'generate'
|
||||
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const response = await api.taskmaster.getTemplates();
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTemplates(data.templates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const handleSelectTemplate = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
// Find placeholders in template content
|
||||
const placeholders = template.content.match(/\[([^\]]+)\]/g) || [];
|
||||
const uniquePlaceholders = [...new Set(placeholders.map(p => p.slice(1, -1)))];
|
||||
|
||||
const initialCustomizations = {};
|
||||
uniquePlaceholders.forEach(placeholder => {
|
||||
initialCustomizations[placeholder] = '';
|
||||
});
|
||||
|
||||
setCustomizations(initialCustomizations);
|
||||
setStep('customize');
|
||||
};
|
||||
|
||||
const handleApplyTemplate = async () => {
|
||||
if (!selectedTemplate || !currentProject) return;
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
// Apply template
|
||||
const applyResponse = await api.taskmaster.applyTemplate(currentProject.name, {
|
||||
templateId: selectedTemplate.id,
|
||||
fileName,
|
||||
customizations
|
||||
});
|
||||
|
||||
if (!applyResponse.ok) {
|
||||
const error = await applyResponse.json();
|
||||
throw new Error(error.message || 'Failed to apply template');
|
||||
}
|
||||
|
||||
// Parse PRD to generate tasks
|
||||
const parseResponse = await api.taskmaster.parsePRD(currentProject.name, {
|
||||
fileName,
|
||||
numTasks: 10
|
||||
});
|
||||
|
||||
if (!parseResponse.ok) {
|
||||
const error = await parseResponse.json();
|
||||
throw new Error(error.message || 'Failed to generate tasks');
|
||||
}
|
||||
|
||||
setStep('generate');
|
||||
setTimeout(() => {
|
||||
onTemplateApplied();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying template:', error);
|
||||
alert(`Error: ${error.message}`);
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<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 templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{step === 'select' ? 'Select PRD Template' :
|
||||
step === 'customize' ? 'Customize Template' :
|
||||
'Generating Tasks'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 'select' && (
|
||||
<div className="space-y-3">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{template.name}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{template.description}</p>
|
||||
<span className="inline-block text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded mt-2">
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-400 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'customize' && selectedTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="prd.txt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Object.keys(customizations).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Customize Template
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(customizations).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setCustomizations(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder={`Enter ${key.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => setStep('select')}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplyTemplate}
|
||||
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded disabled:opacity-50"
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? 'Applying...' : 'Apply & Generate Tasks'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'generate' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Template Applied Successfully!
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Your PRD has been created and tasks are being generated...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextTaskBanner;
|
||||
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 ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import LoginModal from './LoginModal';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
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: '' });
|
||||
|
||||
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">
|
||||
<ClaudeLogo size={20} />
|
||||
</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">
|
||||
<CursorLogo size={20} />
|
||||
</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">
|
||||
<CodexLogo 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
871
src/components/PRDEditor.jsx
Normal file
@@ -0,0 +1,871 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, FileText, Sparkles, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { api, authenticatedFetch } from '../utils/api';
|
||||
|
||||
const PRDEditor = ({
|
||||
file,
|
||||
onClose,
|
||||
projectPath,
|
||||
project, // Add project object
|
||||
initialContent = '',
|
||||
isNewFile = false,
|
||||
onSave
|
||||
}) => {
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [loading, setLoading] = useState(!isNewFile);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [wordWrap, setWordWrap] = useState(true); // Default to true for markdown
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
|
||||
const [existingPRDs, setExistingPRDs] = useState([]);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
|
||||
const PRD_TEMPLATE = `# Product Requirements Document - Example Project
|
||||
|
||||
## 1. Overview
|
||||
**Product Name:** AI-Powered Task Manager
|
||||
**Version:** 1.0
|
||||
**Date:** 2024-12-27
|
||||
**Author:** Development Team
|
||||
|
||||
This document outlines the requirements for building an AI-powered task management application that integrates with development workflows and provides intelligent task breakdown and prioritization.
|
||||
|
||||
## 2. Objectives
|
||||
- Create an intuitive task management system that works seamlessly with developer tools
|
||||
- Provide AI-powered task generation from high-level requirements
|
||||
- Enable real-time collaboration and progress tracking
|
||||
- Integrate with popular development environments (VS Code, Cursor, etc.)
|
||||
|
||||
### Success Metrics
|
||||
- User adoption rate > 80% within development teams
|
||||
- Task completion rate improvement of 25%
|
||||
- Time-to-delivery reduction of 15%
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### Core Functionality
|
||||
- As a project manager, I want to create PRDs that automatically generate detailed tasks so I can save time on project planning
|
||||
- As a developer, I want to see my next task clearly highlighted so I can maintain focus
|
||||
- As a team lead, I want to track progress across multiple projects so I can provide accurate status updates
|
||||
- As a developer, I want tasks to be broken down into implementable subtasks so I can work more efficiently
|
||||
|
||||
### AI Integration
|
||||
- As a user, I want to describe a feature in natural language and get detailed implementation tasks so I can start working immediately
|
||||
- As a project manager, I want the AI to analyze task complexity and suggest appropriate time estimates
|
||||
- As a developer, I want intelligent task prioritization based on dependencies and deadlines
|
||||
|
||||
### Collaboration
|
||||
- As a team member, I want to see real-time updates when tasks are completed so I can coordinate my work
|
||||
- As a stakeholder, I want to view project progress through intuitive dashboards
|
||||
- As a developer, I want to add implementation notes to tasks for future reference
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### Task Management
|
||||
- Create, edit, and delete tasks with rich metadata (priority, status, dependencies, estimates)
|
||||
- Hierarchical task structure with subtasks and sub-subtasks
|
||||
- Real-time status updates and progress tracking
|
||||
- Dependency management with circular dependency detection
|
||||
- Bulk operations (move, update status, assign)
|
||||
|
||||
### AI Features
|
||||
- Natural language PRD parsing to generate structured tasks
|
||||
- Intelligent task breakdown with complexity analysis
|
||||
- Automated subtask generation with implementation details
|
||||
- Smart dependency suggestion
|
||||
- Progress prediction based on historical data
|
||||
|
||||
### Integration Features
|
||||
- VS Code/Cursor extension for in-editor task management
|
||||
- Git integration for linking commits to tasks
|
||||
- API for third-party tool integration
|
||||
- Webhook support for external notifications
|
||||
- CLI tool for command-line task management
|
||||
|
||||
### User Interface
|
||||
- Responsive web application (desktop and mobile)
|
||||
- Multiple view modes (Kanban, list, calendar)
|
||||
- Dark/light theme support
|
||||
- Drag-and-drop task organization
|
||||
- Advanced filtering and search capabilities
|
||||
- Keyboard shortcuts for power users
|
||||
|
||||
## 5. Technical Requirements
|
||||
|
||||
### Frontend
|
||||
- React.js with TypeScript for type safety
|
||||
- Modern UI framework (Tailwind CSS)
|
||||
- State management (Context API or Redux)
|
||||
- Real-time updates via WebSockets
|
||||
- Progressive Web App (PWA) support
|
||||
- Accessibility compliance (WCAG 2.1 AA)
|
||||
|
||||
### Backend
|
||||
- Node.js with Express.js framework
|
||||
- RESTful API design with OpenAPI documentation
|
||||
- Real-time communication via Socket.io
|
||||
- Background job processing
|
||||
- Rate limiting and security middleware
|
||||
|
||||
### AI Integration
|
||||
- Integration with multiple AI providers (OpenAI, Anthropic, etc.)
|
||||
- Fallback model support
|
||||
- Context-aware prompt engineering
|
||||
- Token usage optimization
|
||||
- Model response caching
|
||||
|
||||
### Database
|
||||
- Primary: PostgreSQL for relational data
|
||||
- Cache: Redis for session management and real-time features
|
||||
- Full-text search capabilities
|
||||
- Database migrations and seeding
|
||||
- Backup and recovery procedures
|
||||
|
||||
### Infrastructure
|
||||
- Docker containerization
|
||||
- Cloud deployment (AWS/GCP/Azure)
|
||||
- Auto-scaling capabilities
|
||||
- Monitoring and logging (structured logging)
|
||||
- CI/CD pipeline with automated testing
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- Page load time < 2 seconds
|
||||
- API response time < 500ms for 95% of requests
|
||||
- Support for 1000+ concurrent users
|
||||
- Efficient handling of large task lists (10,000+ tasks)
|
||||
|
||||
### Security
|
||||
- JWT-based authentication with refresh tokens
|
||||
- Role-based access control (RBAC)
|
||||
- Data encryption at rest and in transit
|
||||
- Regular security audits and penetration testing
|
||||
- GDPR and privacy compliance
|
||||
|
||||
### Reliability
|
||||
- 99.9% uptime SLA
|
||||
- Graceful error handling and recovery
|
||||
- Data backup every 6 hours with point-in-time recovery
|
||||
- Disaster recovery plan with RTO < 4 hours
|
||||
|
||||
### Scalability
|
||||
- Horizontal scaling for both frontend and backend
|
||||
- Database read replicas for query optimization
|
||||
- CDN for static asset delivery
|
||||
- Microservices architecture for future expansion
|
||||
|
||||
## 7. User Experience Design
|
||||
|
||||
### Information Architecture
|
||||
- Intuitive navigation with breadcrumbs
|
||||
- Context-aware menus and actions
|
||||
- Progressive disclosure of complex features
|
||||
- Consistent design patterns throughout
|
||||
|
||||
### Interaction Design
|
||||
- Smooth animations and transitions
|
||||
- Immediate feedback for user actions
|
||||
- Undo/redo functionality for critical operations
|
||||
- Smart defaults and auto-save features
|
||||
|
||||
### Visual Design
|
||||
- Modern, clean interface with plenty of whitespace
|
||||
- Consistent color scheme and typography
|
||||
- Clear visual hierarchy with proper contrast ratios
|
||||
- Iconography that supports comprehension
|
||||
|
||||
## 8. Integration Requirements
|
||||
|
||||
### Development Tools
|
||||
- VS Code extension with task panel and quick actions
|
||||
- Cursor IDE integration with AI task suggestions
|
||||
- Terminal CLI for command-line workflow
|
||||
- Browser extension for web-based tools
|
||||
|
||||
### Third-Party Services
|
||||
- GitHub/GitLab integration for issue sync
|
||||
- Slack/Discord notifications
|
||||
- Calendar integration (Google Calendar, Outlook)
|
||||
- Time tracking tools (Toggl, Harvest)
|
||||
|
||||
### APIs and Webhooks
|
||||
- RESTful API with comprehensive documentation
|
||||
- GraphQL endpoint for complex queries
|
||||
- Webhook system for external integrations
|
||||
- SDK development for major programming languages
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: Core MVP (8-10 weeks)
|
||||
- Basic task management (CRUD operations)
|
||||
- Simple AI task generation
|
||||
- Web interface with essential features
|
||||
- User authentication and basic permissions
|
||||
|
||||
### Phase 2: Enhanced Features (6-8 weeks)
|
||||
- Advanced AI features (complexity analysis, subtask generation)
|
||||
- Real-time collaboration
|
||||
- Mobile-responsive design
|
||||
- Integration with one development tool (VS Code)
|
||||
|
||||
### Phase 3: Enterprise Features (4-6 weeks)
|
||||
- Advanced user management and permissions
|
||||
- API and webhook system
|
||||
- Performance optimization
|
||||
- Comprehensive testing and security audit
|
||||
|
||||
### Phase 4: Ecosystem Expansion (4-6 weeks)
|
||||
- Additional tool integrations
|
||||
- Mobile app development
|
||||
- Advanced analytics and reporting
|
||||
- Third-party marketplace preparation
|
||||
|
||||
## 10. Risk Assessment
|
||||
|
||||
### Technical Risks
|
||||
- AI model reliability and cost management
|
||||
- Real-time synchronization complexity
|
||||
- Database performance with large datasets
|
||||
- Integration complexity with multiple tools
|
||||
|
||||
### Business Risks
|
||||
- User adoption in competitive market
|
||||
- AI provider dependency
|
||||
- Data privacy and security concerns
|
||||
- Feature scope creep and timeline delays
|
||||
|
||||
### Mitigation Strategies
|
||||
- Implement robust error handling and fallback systems
|
||||
- Develop comprehensive testing strategy
|
||||
- Create detailed documentation and user guides
|
||||
- Establish clear project scope and change management process
|
||||
|
||||
## 11. Success Criteria
|
||||
|
||||
### Development Milestones
|
||||
- Alpha version with core features completed
|
||||
- Beta version with selected user group feedback
|
||||
- Production-ready version with full feature set
|
||||
- Post-launch iterations based on user feedback
|
||||
|
||||
### Business Metrics
|
||||
- User engagement and retention rates
|
||||
- Task completion and productivity metrics
|
||||
- Customer satisfaction scores (NPS > 50)
|
||||
- Revenue targets and subscription growth
|
||||
|
||||
## 12. Appendices
|
||||
|
||||
### Glossary
|
||||
- **PRD**: Product Requirements Document
|
||||
- **AI**: Artificial Intelligence
|
||||
- **CRUD**: Create, Read, Update, Delete
|
||||
- **API**: Application Programming Interface
|
||||
- **CI/CD**: Continuous Integration/Continuous Deployment
|
||||
|
||||
### References
|
||||
- Industry best practices for task management
|
||||
- AI integration patterns and examples
|
||||
- Security and compliance requirements
|
||||
- Performance benchmarking data
|
||||
|
||||
---
|
||||
|
||||
**Document Control:**
|
||||
- Version: 1.0
|
||||
- Last Updated: December 27, 2024
|
||||
- Next Review: January 15, 2025
|
||||
- Approved By: Product Owner, Technical Lead`;
|
||||
|
||||
// Initialize filename and load content
|
||||
useEffect(() => {
|
||||
const initializeEditor = async () => {
|
||||
// Set initial filename
|
||||
if (file?.name) {
|
||||
setFileName(file.name.replace(/\.(txt|md)$/, '')); // Remove extension for editing
|
||||
} else if (isNewFile) {
|
||||
// Generate default filename based on current date
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
setFileName(`prd-${dateStr}`);
|
||||
}
|
||||
|
||||
// Load content
|
||||
if (isNewFile) {
|
||||
setContent(PRD_TEMPLATE);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If content is directly provided (for existing PRDs loaded from API)
|
||||
if (file.content) {
|
||||
setContent(file.content);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to loading from file path (legacy support)
|
||||
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 || PRD_TEMPLATE);
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD file:', error);
|
||||
setContent(`# Error Loading PRD\n\nError: ${error.message}\n\nFile: ${file?.name || 'New PRD'}\nPath: ${file?.path || 'Not saved yet'}\n\n${PRD_TEMPLATE}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
}, [file, projectPath, isNewFile]);
|
||||
|
||||
// Fetch existing PRDs to check for conflicts
|
||||
useEffect(() => {
|
||||
const fetchExistingPRDs = async () => {
|
||||
if (!project?.name) {
|
||||
console.log('No project name available:', project);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Fetching PRDs for project:', project.name);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Fetched existing PRDs:', data.prds);
|
||||
setExistingPRDs(data.prds || []);
|
||||
} else {
|
||||
console.log('Failed to fetch PRDs:', response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching existing PRDs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExistingPRDs();
|
||||
}, [project?.name]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!content.trim()) {
|
||||
alert('Please add content before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileName.trim()) {
|
||||
alert('Please provide a filename for the PRD.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
|
||||
const existingFile = existingPRDs.find(prd => prd.name === fullFileName);
|
||||
|
||||
console.log('Save check:', {
|
||||
fullFileName,
|
||||
existingPRDs,
|
||||
existingFile,
|
||||
isExisting: file?.isExisting,
|
||||
fileObject: file,
|
||||
shouldShowModal: existingFile && !file?.isExisting
|
||||
});
|
||||
|
||||
if (existingFile && !file?.isExisting) {
|
||||
console.log('Showing overwrite confirmation modal');
|
||||
// Show confirmation modal for overwrite
|
||||
setShowOverwriteConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSave();
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Ensure filename has .txt extension
|
||||
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
|
||||
|
||||
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(project?.name)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: fullFileName,
|
||||
content
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Save failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Show success feedback
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
|
||||
// Update existing PRDs list
|
||||
const response2 = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
|
||||
if (response2.ok) {
|
||||
const data = await response2.json();
|
||||
setExistingPRDs(data.prds || []);
|
||||
}
|
||||
|
||||
// Call the onSave callback if provided (for UI updates)
|
||||
if (onSave) {
|
||||
await onSave();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving PRD:', error);
|
||||
alert(`Error saving PRD: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const downloadFileName = fileName ? `${fileName}.txt` : 'prd.txt';
|
||||
a.download = downloadFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleGenerateTasks = async () => {
|
||||
if (!content.trim()) {
|
||||
alert('Please add content to the PRD before generating tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show AI-first modal instead of simple confirm
|
||||
setShowGenerateModal(true);
|
||||
};
|
||||
|
||||
|
||||
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]);
|
||||
|
||||
// Simple markdown to HTML converter for preview
|
||||
const renderMarkdown = (markdown) => {
|
||||
return markdown
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/^\- (.*$)/gim, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/gims, '<ul>$1</ul>')
|
||||
.replace(/\n\n/gim, '</p><p>')
|
||||
.replace(/^(?!<[h|u|l])(.*$)/gim, '<p>$1</p>')
|
||||
.replace(/<\/ul>\s*<ul>/gim, '');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading PRD...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-[200] ${
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 shadow-2xl flex flex-col',
|
||||
'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-[85vh] md:max-h-[85vh]'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Mobile: Stack filename and tags vertically for more space */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0">
|
||||
{/* Filename input row - full width on mobile */}
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className="flex items-center min-w-0 flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-2 focus-within:ring-2 focus-within:ring-purple-500 focus-within:border-purple-500 dark:focus-within:ring-purple-400 dark:focus-within:border-purple-400">
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => {
|
||||
// Remove invalid filename characters
|
||||
const sanitizedValue = e.target.value.replace(/[<>:"/\\|?*]/g, '');
|
||||
setFileName(sanitizedValue);
|
||||
}}
|
||||
className="font-medium text-gray-900 dark:text-white bg-transparent border-none outline-none min-w-0 flex-1 text-base sm:text-sm placeholder-gray-400 dark:placeholder-gray-500"
|
||||
placeholder="Enter PRD filename"
|
||||
maxLength={100}
|
||||
/>
|
||||
<span className="text-sm sm:text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-1">.txt</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => document.querySelector('input[placeholder="Enter PRD filename"]')?.focus()}
|
||||
className="p-1 text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
title="Click to edit filename"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags row - moves to second line on mobile for more filename space */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
📋 PRD
|
||||
</span>
|
||||
{isNewFile && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
✨ New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description - smaller on mobile */}
|
||||
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate mt-1">
|
||||
Product Requirements Document
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setPreviewMode(!previewMode)}
|
||||
className={cn(
|
||||
'p-2 md:p-2 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',
|
||||
previewMode
|
||||
? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
)}
|
||||
title={previewMode ? 'Switch to edit mode' : 'Preview markdown'}
|
||||
>
|
||||
<Eye className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
className={cn(
|
||||
'p-2 md:p-2 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',
|
||||
wordWrap
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
)}
|
||||
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 PRD"
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateTasks}
|
||||
disabled={!content.trim()}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors text-sm font-medium',
|
||||
'bg-purple-600 hover:bg-purple-700 text-white',
|
||||
'min-h-[44px] md:min-h-0'
|
||||
)}
|
||||
title="Generate tasks from PRD content"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="hidden md:inline">Generate Tasks</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'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-purple-600 hover:bg-purple-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 PRD'}</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/Preview Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{previewMode ? (
|
||||
<div className="h-full overflow-y-auto p-6 prose prose-gray dark:prose-invert max-w-none">
|
||||
<div
|
||||
className="markdown-preview"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
markdown(),
|
||||
...(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>Words: {content.split(/\s+/).filter(word => word.length > 0).length}</span>
|
||||
<span>Format: Markdown</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Press Ctrl+S to save • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Tasks Modal */}
|
||||
{showGenerateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
|
||||
{/* 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-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Generate Tasks from PRD</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* AI-First Approach */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-purple-900 dark:text-purple-100 mb-2">
|
||||
💡 Pro Tip: Ask Claude Code Directly!
|
||||
</h4>
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200 mb-3">
|
||||
You can simply ask Claude Code in the chat to parse your PRD and generate tasks.
|
||||
The AI assistant will automatically save your PRD and create detailed tasks with implementation details.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-purple-200 dark:border-purple-700 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">
|
||||
"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}. Can you help me parse it and set up the initial tasks?"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
<strong>This will:</strong> Save your PRD, analyze its content, and generate structured tasks with subtasks, dependencies, and implementation details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More Link */}
|
||||
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
For more examples and advanced usage patterns:
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 underline font-medium"
|
||||
>
|
||||
View TaskMaster Documentation →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(false)}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Got it, I'll ask Claude Code directly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overwrite Confirmation Modal */}
|
||||
{showOverwriteConfirm && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowOverwriteConfirm(false)} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="p-2 rounded-full mr-3 bg-yellow-100 dark:bg-yellow-900">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
File Already Exists
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
A PRD file named "{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}" already exists.
|
||||
Do you want to overwrite it with the current content?
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setShowOverwriteConfirm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowOverwriteConfirm(false);
|
||||
await performSave();
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-white bg-yellow-600 hover:bg-yellow-700 rounded-md flex items-center space-x-2 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Overwrite</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PRDEditor;
|
||||
570
src/components/ProjectCreationWizard.jsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
// Wizard state
|
||||
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
|
||||
const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new'
|
||||
|
||||
// Form state
|
||||
const [workspacePath, setWorkspacePath] = useState('');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [selectedGithubToken, setSelectedGithubToken] = useState('');
|
||||
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
|
||||
const [newGithubToken, setNewGithubToken] = useState('');
|
||||
|
||||
// UI state
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [availableTokens, setAvailableTokens] = useState([]);
|
||||
const [loadingTokens, setLoadingTokens] = useState(false);
|
||||
const [pathSuggestions, setPathSuggestions] = useState([]);
|
||||
const [showPathDropdown, setShowPathDropdown] = useState(false);
|
||||
|
||||
// Load available GitHub tokens when needed
|
||||
useEffect(() => {
|
||||
if (step === 2 && workspaceType === 'new' && githubUrl) {
|
||||
loadGithubTokens();
|
||||
}
|
||||
}, [step, workspaceType, githubUrl]);
|
||||
|
||||
// Load path suggestions
|
||||
useEffect(() => {
|
||||
if (workspacePath.length > 2) {
|
||||
loadPathSuggestions(workspacePath);
|
||||
} else {
|
||||
setPathSuggestions([]);
|
||||
setShowPathDropdown(false);
|
||||
}
|
||||
}, [workspacePath]);
|
||||
|
||||
const loadGithubTokens = async () => {
|
||||
try {
|
||||
setLoadingTokens(true);
|
||||
const response = await api.get('/settings/credentials?type=github_token');
|
||||
const data = await response.json();
|
||||
|
||||
const activeTokens = (data.credentials || []).filter(t => t.is_active);
|
||||
setAvailableTokens(activeTokens);
|
||||
|
||||
// Auto-select first token if available
|
||||
if (activeTokens.length > 0 && !selectedGithubToken) {
|
||||
setSelectedGithubToken(activeTokens[0].id.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading GitHub tokens:', error);
|
||||
} finally {
|
||||
setLoadingTokens(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPathSuggestions = async (inputPath) => {
|
||||
try {
|
||||
// Extract the directory to browse (parent of input)
|
||||
const lastSlash = inputPath.lastIndexOf('/');
|
||||
const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
|
||||
|
||||
const response = await api.browseFilesystem(dirPath);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.suggestions) {
|
||||
// Filter suggestions based on the input
|
||||
const filtered = data.suggestions.filter(s =>
|
||||
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
|
||||
);
|
||||
setPathSuggestions(filtered.slice(0, 5));
|
||||
setShowPathDropdown(filtered.length > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading path suggestions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setError(null);
|
||||
|
||||
if (step === 1) {
|
||||
if (!workspaceType) {
|
||||
setError('Please select whether you have an existing workspace or want to create a new one');
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
if (!workspacePath.trim()) {
|
||||
setError('Please provide a workspace path');
|
||||
return;
|
||||
}
|
||||
|
||||
// No validation for GitHub token - it's optional (only needed for private repos)
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
setStep(step - 1);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
workspaceType,
|
||||
path: workspacePath.trim(),
|
||||
};
|
||||
|
||||
// Add GitHub info if creating new workspace with GitHub URL
|
||||
if (workspaceType === 'new' && githubUrl) {
|
||||
payload.githubUrl = githubUrl.trim();
|
||||
|
||||
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||
payload.githubTokenId = parseInt(selectedGithubToken);
|
||||
} else if (tokenMode === 'new' && newGithubToken) {
|
||||
payload.newGithubToken = newGithubToken.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.createWorkspace(payload);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
// Success!
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
setError(error.message || 'Failed to create workspace');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPathSuggestion = (suggestion) => {
|
||||
setWorkspacePath(suggestion.path);
|
||||
setShowPathDropdown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Create New Project
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
|
||||
s < step
|
||||
? 'bg-green-500 text-white'
|
||||
: s === step
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{s < step ? <Check className="w-4 h-4" /> : s}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
|
||||
{s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
|
||||
</span>
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded ${
|
||||
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 min-h-[300px]">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Choose workspace type */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Do you already have a workspace, or would you like to create a new one?
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Existing Workspace */}
|
||||
<button
|
||||
onClick={() => setWorkspaceType('existing')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
workspaceType === 'existing'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Existing Workspace
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
I already have a workspace on my server and just need to add it to the project list
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* New Workspace */}
|
||||
<button
|
||||
onClick={() => setWorkspaceType('new')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
workspaceType === 'new'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
New Workspace
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a new workspace, optionally clone from a GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure workspace */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* Workspace Path */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
|
||||
className="w-full"
|
||||
/>
|
||||
{showPathDropdown && pathSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{pathSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => selectPathSuggestion(suggestion)}
|
||||
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{workspaceType === 'existing'
|
||||
? 'Full path to your existing workspace directory'
|
||||
: 'Full path where the new workspace will be created'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub URL (only for new workspace) */}
|
||||
{workspaceType === 'new' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
GitHub URL (Optional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => setGithubUrl(e.target.value)}
|
||||
placeholder="https://github.com/username/repository"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Leave empty to create an empty workspace, or provide a GitHub URL to clone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token (only if GitHub URL is provided) */}
|
||||
{githubUrl && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
GitHub Authentication (Optional)
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Only required for private repositories. Public repos can be cloned without authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingTokens ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading stored tokens...
|
||||
</div>
|
||||
) : availableTokens.length > 0 ? (
|
||||
<>
|
||||
{/* Token Selection Tabs */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setTokenMode('stored')}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
tokenMode === 'stored'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Stored Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTokenMode('new')}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
tokenMode === 'new'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
New Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTokenMode('none');
|
||||
setSelectedGithubToken('');
|
||||
setNewGithubToken('');
|
||||
}}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
tokenMode === 'none'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
None (Public)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tokenMode === 'stored' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select Token
|
||||
</label>
|
||||
<select
|
||||
value={selectedGithubToken}
|
||||
onChange={(e) => setSelectedGithubToken(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">-- Select a token --</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id}>
|
||||
{token.credential_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : tokenMode === 'new' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
GitHub Token
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
This token will be used only for this operation
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 <strong>Public repositories</strong> don't require authentication. You can skip providing a token if cloning a public repo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
GitHub Token (Optional for Public Repos)
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Review Your Configuration
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Workspace Type:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Path:</span>
|
||||
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||
{workspacePath}
|
||||
</span>
|
||||
</div>
|
||||
{workspaceType === 'new' && githubUrl && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Clone From:</span>
|
||||
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||
{githubUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
|
||||
<span className="text-xs text-gray-900 dark:text-white">
|
||||
{tokenMode === 'stored' && selectedGithubToken
|
||||
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||
: tokenMode === 'new' && newGithubToken
|
||||
? 'Using provided token'
|
||||
: 'No authentication'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{workspaceType === 'existing'
|
||||
? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.'
|
||||
: githubUrl
|
||||
? 'A new workspace will be created and the repository will be cloned from GitHub.'
|
||||
: 'An empty workspace directory will be created at the specified path.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={step === 1 ? onClose : handleBack}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{step === 1 ? (
|
||||
'Cancel'
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={step === 3 ? handleCreate : handleNext}
|
||||
disabled={isCreating || (step === 1 && !workspaceType)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : step === 3 ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Create Project
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCreationWizard;
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
@@ -24,7 +25,19 @@ const LoadingScreen = () => (
|
||||
);
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup } = useAuth();
|
||||
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <Onboarding onComplete={refreshOnboardingStatus} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
@@ -38,6 +51,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,18 +12,21 @@ import {
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Languages
|
||||
Languages,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const QuickSettingsPanel = ({
|
||||
isOpen,
|
||||
const QuickSettingsPanel = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
autoExpandTools,
|
||||
onAutoExpandChange,
|
||||
showRawParameters,
|
||||
onShowRawParametersChange,
|
||||
showThinking,
|
||||
onShowThinkingChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
sendByCtrlEnter,
|
||||
@@ -36,11 +39,170 @@ const QuickSettingsPanel = ({
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// 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
|
||||
|
||||
useEffect(() => {
|
||||
setLocalIsOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const newState = !localIsOpen;
|
||||
setLocalIsOpen(newState);
|
||||
onToggle(newState);
|
||||
@@ -48,28 +210,41 @@ const QuickSettingsPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull Tab */}
|
||||
<div
|
||||
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
|
||||
{/* 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 ${
|
||||
localIsOpen ? 'right-64' : 'right-0'
|
||||
} z-50 transition-all duration-150 ease-out`}
|
||||
} 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 ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
|
||||
>
|
||||
<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" />
|
||||
) : 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>
|
||||
|
||||
{/* 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 ${
|
||||
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 ${
|
||||
localIsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
@@ -83,7 +258,7 @@ const QuickSettingsPanel = ({
|
||||
</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>
|
||||
@@ -110,7 +285,7 @@ const QuickSettingsPanel = ({
|
||||
type="checkbox"
|
||||
checked={autoExpandTools}
|
||||
onChange={(e) => onAutoExpandChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -123,7 +298,20 @@ const QuickSettingsPanel = ({
|
||||
type="checkbox"
|
||||
checked={showRawParameters}
|
||||
onChange={(e) => onShowRawParametersChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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
|
||||
</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 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>
|
||||
@@ -140,7 +328,7 @@ const QuickSettingsPanel = ({
|
||||
type="checkbox"
|
||||
checked={autoScrollToBottom}
|
||||
onChange={(e) => onAutoScrollChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -158,7 +346,7 @@ const QuickSettingsPanel = ({
|
||||
type="checkbox"
|
||||
checked={sendByCtrlEnter}
|
||||
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
// CSS to remove xterm focus outline
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
outline: none !important;
|
||||
@@ -18,7 +17,6 @@ const xtermStyles = `
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.type = 'text/css';
|
||||
@@ -26,10 +24,7 @@ if (typeof document !== 'undefined') {
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Global store for shell sessions to persist across tab switches
|
||||
const shellSessions = new Map();
|
||||
|
||||
function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||
const terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
@@ -40,177 +35,211 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
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();
|
||||
};
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
|
||||
// Disconnect from shell function
|
||||
const disconnectFromShell = () => {
|
||||
|
||||
useEffect(() => {
|
||||
selectedProjectRef.current = selectedProject;
|
||||
selectedSessionRef.current = selectedSession;
|
||||
initialCommandRef.current = initialCommand;
|
||||
isPlainShellRef.current = isPlainShell;
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
});
|
||||
|
||||
const connectWebSocket = useCallback(async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||
} else {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
fitAddon.current.fit();
|
||||
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'init',
|
||||
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
|
||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
|
||||
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
|
||||
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
isPlainShell: isPlainShellRef.current
|
||||
}));
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'output') {
|
||||
let output = data.data;
|
||||
|
||||
if (isPlainShellRef.current && onProcessCompleteRef.current) {
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessCompleteRef.current(0);
|
||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||
if (exitCode !== 0) {
|
||||
onProcessCompleteRef.current(exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.write(output);
|
||||
}
|
||||
} else if (data.type === 'url_open') {
|
||||
window.open(data.url, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
} catch (error) {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [isConnecting, isConnected]);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
setIsConnecting(true);
|
||||
connectWebSocket();
|
||||
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
|
||||
|
||||
const disconnectFromShell = useCallback(() => {
|
||||
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
|
||||
terminal.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessionDisplayName = useMemo(() => {
|
||||
if (!selectedSession) return null;
|
||||
return selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
}, [selectedSession]);
|
||||
|
||||
const sessionDisplayNameShort = useMemo(() => {
|
||||
if (!sessionDisplayName) return null;
|
||||
return sessionDisplayName.slice(0, 30);
|
||||
}, [sessionDisplayName]);
|
||||
|
||||
const sessionDisplayNameLong = useMemo(() => {
|
||||
if (!sessionDisplayName) return null;
|
||||
return sessionDisplayName.slice(0, 50);
|
||||
}, [sessionDisplayName]);
|
||||
|
||||
// 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]);
|
||||
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
|
||||
|
||||
// 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) {
|
||||
if (!terminalRef.current || !selectedProject || isRestarting || 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
|
||||
allowProposedApi: true,
|
||||
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',
|
||||
@@ -219,8 +248,6 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
|
||||
// Bright ANSI colors (8-15)
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
@@ -229,10 +256,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
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',
|
||||
@@ -242,35 +266,27 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
});
|
||||
|
||||
fitAddon.current = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const webglAddon = new WebglAddon();
|
||||
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.loadAddon(clipboardAddon);
|
||||
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
|
||||
|
||||
try {
|
||||
terminal.current.loadAddon(webglAddon);
|
||||
} catch (error) {
|
||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
@@ -279,20 +295,16 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
data: text
|
||||
}));
|
||||
}
|
||||
}).catch(err => {
|
||||
// Failed to read clipboard
|
||||
});
|
||||
}).catch(() => {});
|
||||
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',
|
||||
@@ -302,10 +314,8 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
// Handle terminal input
|
||||
setIsInitialized(true);
|
||||
terminal.current.onData((data) => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
@@ -315,12 +325,10 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
}
|
||||
});
|
||||
|
||||
// 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',
|
||||
@@ -338,174 +346,23 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
|
||||
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) {
|
||||
}
|
||||
|
||||
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
|
||||
ws.current.close();
|
||||
}
|
||||
ws.current = null;
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.dispose();
|
||||
terminal.current = null;
|
||||
}
|
||||
};
|
||||
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, 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: selectedSession?.id,
|
||||
hasSession: !!selectedSession,
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
};
|
||||
|
||||
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 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 (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
||||
connectToShell();
|
||||
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
@@ -523,16 +380,23 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (minimal) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-900">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
</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 && (
|
||||
<span className="text-xs text-blue-300">
|
||||
({selectedSession.summary.slice(0, 30)}...)
|
||||
({sessionDisplayNameShort}...)
|
||||
</span>
|
||||
)}
|
||||
{!selectedSession && (
|
||||
@@ -558,7 +422,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={restartShell}
|
||||
disabled={isRestarting || isConnected}
|
||||
@@ -574,18 +438,15 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
</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">
|
||||
@@ -600,16 +461,17 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
<span>Continue in Shell</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{selectedSession ?
|
||||
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
|
||||
'Start a new Claude session'
|
||||
{isPlainShell ?
|
||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
selectedSession ?
|
||||
`Resume session: ${sessionDisplayNameLong}...` :
|
||||
'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">
|
||||
@@ -618,7 +480,10 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
Starting Claude CLI in {selectedProject.displayName}
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -7,7 +8,13 @@ import { Input } from './ui/input';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
import CodexLogo from './CodexLogo.jsx';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import ProjectCreationWizard from './ProjectCreationWizard';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
||||
const formatTimeAgo = (dateString, currentTime) => {
|
||||
@@ -35,12 +42,12 @@ const formatTimeAgo = (dateString, currentTime) => {
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
function Sidebar({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
function Sidebar({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
onSessionDelete,
|
||||
onProjectDelete,
|
||||
@@ -50,14 +57,16 @@ function Sidebar({
|
||||
updateAvailable,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
onShowVersionModal
|
||||
releaseInfo,
|
||||
onShowVersionModal,
|
||||
isPWA,
|
||||
isMobile,
|
||||
onToggleSidebar
|
||||
}) {
|
||||
const [expandedProjects, setExpandedProjects] = useState(new Set());
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [newProjectPath, setNewProjectPath] = useState('');
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [loadingSessions, setLoadingSessions] = useState({});
|
||||
const [additionalSessions, setAdditionalSessions] = useState({});
|
||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
||||
@@ -69,6 +78,10 @@ function Sidebar({
|
||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
// TaskMaster context
|
||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
|
||||
|
||||
// Starred projects state - persisted in localStorage
|
||||
const [starredProjects, setStarredProjects] = useState(() => {
|
||||
@@ -133,7 +146,7 @@ function Sidebar({
|
||||
useEffect(() => {
|
||||
const loadSortOrder = () => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||
const savedSettings = localStorage.getItem('claude-settings');
|
||||
if (savedSettings) {
|
||||
const settings = JSON.parse(savedSettings);
|
||||
setProjectSortOrder(settings.projectSortOrder || 'name');
|
||||
@@ -148,7 +161,7 @@ function Sidebar({
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'claude-tools-settings') {
|
||||
if (e.key === 'claude-settings') {
|
||||
loadSortOrder();
|
||||
}
|
||||
};
|
||||
@@ -168,16 +181,22 @@ function Sidebar({
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const toggleProject = (projectName) => {
|
||||
const newExpanded = new Set(expandedProjects);
|
||||
if (newExpanded.has(projectName)) {
|
||||
newExpanded.delete(projectName);
|
||||
} else {
|
||||
const newExpanded = new Set();
|
||||
// If clicking the already-expanded project, collapse it (newExpanded stays empty)
|
||||
// If clicking a different project, expand only that one
|
||||
if (!expandedProjects.has(projectName)) {
|
||||
newExpanded.add(projectName);
|
||||
}
|
||||
setExpandedProjects(newExpanded);
|
||||
};
|
||||
|
||||
// Wrapper to attach project context when session is clicked
|
||||
const handleSessionClick = (session, projectName) => {
|
||||
onSessionSelect({ ...session, __projectName: projectName });
|
||||
};
|
||||
|
||||
// Starred projects utility functions
|
||||
const toggleStarProject = (projectName) => {
|
||||
const newStarred = new Set(starredProjects);
|
||||
@@ -202,9 +221,17 @@ function Sidebar({
|
||||
|
||||
// Helper function to get all sessions for a project (initial + additional)
|
||||
const getAllSessions = (project) => {
|
||||
const initialSessions = project.sessions || [];
|
||||
const additional = additionalSessions[project.name] || [];
|
||||
return [...initialSessions, ...additional];
|
||||
// Combine Claude, Cursor, and Codex sessions; Sidebar will display icon per row
|
||||
const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
|
||||
const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' }));
|
||||
const codexSessions = (project.codexSessions || []).map(s => ({ ...s, __provider: 'codex' }));
|
||||
// Sort by most recent activity/date
|
||||
const normalizeDate = (s) => {
|
||||
if (s.__provider === 'cursor') return new Date(s.createdAt);
|
||||
if (s.__provider === 'codex') return new Date(s.createdAt || s.lastActivity);
|
||||
return new Date(s.lastActivity);
|
||||
};
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
|
||||
};
|
||||
|
||||
// Helper function to get the last activity date for a project
|
||||
@@ -276,25 +303,39 @@ function Sidebar({
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const deleteSession = async (projectName, sessionId) => {
|
||||
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
|
||||
if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.deleteSession(projectName, sessionId);
|
||||
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
|
||||
|
||||
// Call the appropriate API based on provider
|
||||
let response;
|
||||
if (provider === 'codex') {
|
||||
response = await api.deleteCodexSession(sessionId);
|
||||
} else {
|
||||
response = await api.deleteSession(projectName, sessionId);
|
||||
}
|
||||
|
||||
console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status });
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Sidebar] Session deleted successfully, calling callback');
|
||||
// Call parent callback if provided
|
||||
if (onSessionDelete) {
|
||||
onSessionDelete(sessionId);
|
||||
} else {
|
||||
console.warn('[Sidebar] No onSessionDelete callback provided');
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to delete session');
|
||||
const errorText = await response.text();
|
||||
console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText });
|
||||
alert('Failed to delete session. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
console.error('[Sidebar] Error deleting session:', error);
|
||||
alert('Error deleting session. Please try again.');
|
||||
}
|
||||
};
|
||||
@@ -336,6 +377,10 @@ function Sidebar({
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
// Save the path to recent paths before clearing
|
||||
saveToRecentPaths(newProjectPath.trim());
|
||||
|
||||
setShowNewProject(false);
|
||||
setNewProjectPath('');
|
||||
|
||||
@@ -413,63 +458,117 @@ function Sidebar({
|
||||
return displayName.includes(searchLower) || projectName.includes(searchLower);
|
||||
});
|
||||
|
||||
// Enhanced project selection that updates both the main UI and TaskMaster context
|
||||
const handleProjectSelect = (project) => {
|
||||
// Call the original project select handler
|
||||
onProjectSelect(project);
|
||||
|
||||
// Update TaskMaster context with the selected project
|
||||
setCurrentProject(project);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card md:select-none">
|
||||
<>
|
||||
{/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
|
||||
{showNewProject && ReactDOM.createPortal(
|
||||
<ProjectCreationWizard
|
||||
onClose={() => setShowNewProject(false)}
|
||||
onProjectCreated={(project) => {
|
||||
// Refresh projects list after creation
|
||||
if (window.refreshProjects) {
|
||||
window.refreshProjects();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="md:p-4 md:border-b md:border-border">
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden md:flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh projects and sessions (Ctrl+R)"
|
||||
{import.meta.env.VITE_IS_PLATFORM === 'true' ? (
|
||||
<a
|
||||
href="https://cloudcli.ai/dashboard"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
|
||||
title="View Environments"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
onClick={() => setShowNewProject(true)}
|
||||
title="Create new project (Ctrl+N)"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<div className="md:hidden p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm group-hover:shadow-md transition-shadow">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">Projects</p>
|
||||
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{onToggleSidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
|
||||
onClick={onToggleSidebar}
|
||||
title="Hide sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<div
|
||||
className="md:hidden p-3 border-b border-border"
|
||||
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{import.meta.env.VITE_IS_PLATFORM === 'true' ? (
|
||||
<a
|
||||
href="https://cloudcli.ai/dashboard"
|
||||
className="flex items-center gap-3 active:opacity-70 transition-opacity"
|
||||
title="View Environments"
|
||||
>
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="w-8 h-8 rounded-md bg-background border border-border flex items-center justify-center active:scale-95 transition-all duration-150"
|
||||
@@ -495,109 +594,43 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Project Form */}
|
||||
{showNewProject && (
|
||||
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
|
||||
{/* Desktop Form */}
|
||||
<div className="hidden md:block space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Create New Project
|
||||
</div>
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
placeholder="/path/to/project or relative/path"
|
||||
className="text-sm focus:ring-2 focus:ring-primary/20"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewProject();
|
||||
if (e.key === 'Escape') cancelNewProject();
|
||||
|
||||
{/* Action Buttons - Desktop only - Always show when not loading */}
|
||||
{!isLoading && !isMobile && (
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
|
||||
onClick={() => setShowNewProject(true)}
|
||||
title="Create new project"
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
||||
New Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createNewProject}
|
||||
disabled={!newProjectPath.trim() || creatingProject}
|
||||
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{creatingProject ? 'Creating...' : 'Create Project'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
className="h-8 text-xs hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Form - Simple Overlay */}
|
||||
<div className="md:hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
||||
<FolderPlus className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">New Project</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
placeholder="/path/to/project or relative/path"
|
||||
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewProject();
|
||||
if (e.key === 'Escape') cancelNewProject();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
variant="outline"
|
||||
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createNewProject}
|
||||
disabled={!newProjectPath.trim() || creatingProject}
|
||||
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
|
||||
>
|
||||
{creatingProject ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safe area for mobile */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
disabled={isRefreshing}
|
||||
title="Refresh projects and sessions (Ctrl+R)"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Filter */}
|
||||
|
||||
{/* Search Filter - Only show when there are projects */}
|
||||
{projects.length > 0 && !isLoading && (
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||
<div className="relative">
|
||||
@@ -713,9 +746,25 @@ function Sidebar({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{project.displayName}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{project.displayName}
|
||||
</h3>
|
||||
{tasksEnabled && (
|
||||
<TaskIndicator
|
||||
status={(() => {
|
||||
const projectConfigured = project.taskmaster?.hasTaskmaster;
|
||||
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
|
||||
if (projectConfigured && mcpConfigured) return 'fully-configured';
|
||||
if (projectConfigured) return 'taskmaster-only';
|
||||
if (mcpConfigured) return 'mcp-only';
|
||||
return 'not-configured';
|
||||
})()}
|
||||
size="xs"
|
||||
className="hidden md:inline-flex flex-shrink-0 ml-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
const sessionCount = getAllSessions(project).length;
|
||||
@@ -821,13 +870,13 @@ function Sidebar({
|
||||
onClick={() => {
|
||||
// Desktop behavior: select project and toggle
|
||||
if (selectedProject?.name !== project.name) {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
}
|
||||
toggleProject(project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
if (selectedProject?.name !== project.name) {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
}
|
||||
toggleProject(project.name);
|
||||
})}
|
||||
@@ -979,10 +1028,34 @@ function Sidebar({
|
||||
</div>
|
||||
) : (
|
||||
getAllSessions(project).map((session) => {
|
||||
// Handle Claude, Cursor, and Codex session formats
|
||||
const isCursorSession = session.__provider === 'cursor';
|
||||
const isCodexSession = session.__provider === 'codex';
|
||||
|
||||
// Calculate if session is active (within last 10 minutes)
|
||||
const sessionDate = new Date(session.lastActivity);
|
||||
const getSessionDate = () => {
|
||||
if (isCursorSession) return new Date(session.createdAt);
|
||||
if (isCodexSession) return new Date(session.createdAt || session.lastActivity);
|
||||
return new Date(session.lastActivity);
|
||||
};
|
||||
const sessionDate = getSessionDate();
|
||||
const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
|
||||
const isActive = diffInMinutes < 10;
|
||||
|
||||
// Get session display values
|
||||
const getSessionName = () => {
|
||||
if (isCursorSession) return session.name || 'Untitled Session';
|
||||
if (isCodexSession) return session.summary || session.name || 'Codex Session';
|
||||
return session.summary || 'New Session';
|
||||
};
|
||||
const sessionName = getSessionName();
|
||||
const getSessionTime = () => {
|
||||
if (isCursorSession) return session.createdAt;
|
||||
if (isCodexSession) return session.createdAt || session.lastActivity;
|
||||
return session.lastActivity;
|
||||
};
|
||||
const sessionTime = getSessionTime();
|
||||
const messageCount = session.messageCount || 0;
|
||||
|
||||
return (
|
||||
<div key={session.id} className="group relative">
|
||||
@@ -1001,12 +1074,12 @@ function Sidebar({
|
||||
isActive ? "border-green-500/30 bg-green-50/5 dark:bg-green-900/5" : "border-border/30"
|
||||
)}
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
handleProjectSelect(project);
|
||||
handleSessionClick(session, project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
onProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
handleProjectSelect(project);
|
||||
handleSessionClick(session, project.name);
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1014,38 +1087,52 @@ function Sidebar({
|
||||
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
|
||||
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
|
||||
)}>
|
||||
<MessageSquare className={cn(
|
||||
"w-3 h-3",
|
||||
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
|
||||
)} />
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate text-foreground">
|
||||
{session.summary || 'New Session'}
|
||||
{sessionName}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimeAgo(session.lastActivity, currentTime)}
|
||||
{formatTimeAgo(sessionTime, currentTime)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
{messageCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||
{session.messageCount}
|
||||
{messageCount}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Provider tiny icon */}
|
||||
<span className="ml-1 opacity-70">
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile delete button */}
|
||||
<button
|
||||
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
{!isCursorSession && (
|
||||
<button
|
||||
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id, session.__provider))}
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1058,32 +1145,47 @@ function Sidebar({
|
||||
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
|
||||
selectedSession?.id === session.id && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => onSessionSelect(session)}
|
||||
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
|
||||
onClick={() => handleSessionClick(session, project.name)}
|
||||
onTouchEnd={handleTouchClick(() => handleSessionClick(session, project.name))}
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||||
<MessageSquare className="w-3 h-3 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate text-foreground">
|
||||
{session.summary || 'New Session'}
|
||||
{sessionName}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimeAgo(session.lastActivity, currentTime)}
|
||||
{formatTimeAgo(sessionTime, currentTime)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
{messageCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||
{session.messageCount}
|
||||
{messageCount}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-1 opacity-70">
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Desktop hover buttons */}
|
||||
{!isCursorSession && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{editingSession === session.id ? (
|
||||
{editingSession === session.id && !isCodexSession ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1126,40 +1228,24 @@ function Sidebar({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Generate summary button */}
|
||||
{/* <button
|
||||
className="w-6 h-6 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
generateSessionSummary(project.name, session.id);
|
||||
}}
|
||||
title="Generate AI summary for this session"
|
||||
disabled={generatingSummary[`${project.name}-${session.id}`]}
|
||||
>
|
||||
{generatingSummary[`${project.name}-${session.id}`] ? (
|
||||
<div className="w-3 h-3 animate-spin rounded-full border border-blue-600 dark:border-blue-400 border-t-transparent" />
|
||||
) : (
|
||||
<Sparkles className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</button> */}
|
||||
{/* Edit button */}
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(session.id);
|
||||
setEditingSessionName(session.summary || 'New Session');
|
||||
}}
|
||||
title="Manually edit session name"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
{/* Delete button */}
|
||||
{!isCodexSession && (
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(session.id);
|
||||
setEditingSessionName(session.summary || 'New Session');
|
||||
}}
|
||||
title="Manually edit session name"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
}}
|
||||
title="Delete this session permanently"
|
||||
>
|
||||
@@ -1168,6 +1254,7 @@ function Sidebar({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1202,7 +1289,7 @@ function Sidebar({
|
||||
<button
|
||||
className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
onNewSession(project);
|
||||
}}
|
||||
>
|
||||
@@ -1246,8 +1333,10 @@ function Sidebar({
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{releaseInfo?.title || `Version ${latestVersion}`}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1265,8 +1354,10 @@ function Sidebar({
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{releaseInfo?.title || `Version ${latestVersion}`}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1295,11 +1386,12 @@ function Sidebar({
|
||||
onClick={onShowSettings}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
<span className="text-xs">Tools Settings</span>
|
||||
<span className="text-xs">Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar;
|
||||
|
||||
105
src/components/StandaloneShell.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useCallback } 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} 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)
|
||||
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
|
||||
*/
|
||||
function StandaloneShell({
|
||||
project,
|
||||
session = null,
|
||||
command = null,
|
||||
isPlainShell = null,
|
||||
autoConnect = true,
|
||||
onComplete = null,
|
||||
onClose = null,
|
||||
title = null,
|
||||
className = "",
|
||||
showHeader = true,
|
||||
compact = false,
|
||||
minimal = false
|
||||
}) {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||
|
||||
const handleProcessComplete = useCallback((exitCode) => {
|
||||
setIsCompleted(true);
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
}, [onComplete]);
|
||||
|
||||
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 w-full flex flex-col ${className}`}>
|
||||
{/* Optional custom header */}
|
||||
{!minimal && 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 w-full min-h-0">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={session}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
minimal={minimal}
|
||||
autoConnect={minimal ? true : autoConnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StandaloneShell;
|
||||
210
src/components/TaskCard.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, Circle, AlertCircle, Pause, X, ArrowRight, ChevronUp, Minus, Flag } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
const TaskCard = ({
|
||||
task,
|
||||
onClick,
|
||||
showParent = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
borderColor: 'border-green-200 dark:border-green-800',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
textColor: 'text-green-900 dark:text-green-100',
|
||||
statusText: 'Done'
|
||||
};
|
||||
|
||||
case 'in-progress':
|
||||
return {
|
||||
icon: Clock,
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
textColor: 'text-blue-900 dark:text-blue-100',
|
||||
statusText: 'In Progress'
|
||||
};
|
||||
|
||||
case 'review':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
textColor: 'text-amber-900 dark:text-amber-100',
|
||||
statusText: 'Review'
|
||||
};
|
||||
|
||||
case 'deferred':
|
||||
return {
|
||||
icon: Pause,
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-gray-200 dark:border-gray-700',
|
||||
iconColor: 'text-gray-500 dark:text-gray-400',
|
||||
textColor: 'text-gray-700 dark:text-gray-300',
|
||||
statusText: 'Deferred'
|
||||
};
|
||||
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: X,
|
||||
bgColor: 'bg-red-50 dark:bg-red-950',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
textColor: 'text-red-900 dark:text-red-100',
|
||||
statusText: 'Cancelled'
|
||||
};
|
||||
|
||||
case 'pending':
|
||||
default:
|
||||
return {
|
||||
icon: Circle,
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-800',
|
||||
borderColor: 'border-slate-200 dark:border-slate-700',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
textColor: 'text-slate-900 dark:text-slate-100',
|
||||
statusText: 'Pending'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(task.status);
|
||||
const Icon = config.icon;
|
||||
|
||||
const getPriorityIcon = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return (
|
||||
<Tooltip content="High Priority">
|
||||
<div className="w-4 h-4 bg-red-100 dark:bg-red-900/30 rounded flex items-center justify-center">
|
||||
<ChevronUp className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<Tooltip content="Medium Priority">
|
||||
<div className="w-4 h-4 bg-amber-100 dark:bg-amber-900/30 rounded flex items-center justify-center">
|
||||
<Minus className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'low':
|
||||
return (
|
||||
<Tooltip content="Low Priority">
|
||||
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-blue-600 dark:text-blue-400 fill-current" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tooltip content="No Priority Set">
|
||||
<div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
|
||||
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200 cursor-pointer',
|
||||
'p-3 space-y-3',
|
||||
onClick && 'hover:-translate-y-0.5',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header with Task ID, Title, and Priority */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
{/* Task ID and Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip content={`Task ID: ${task.id}`}>
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{task.id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{task.title}
|
||||
</h3>
|
||||
{showParent && task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
Task {task.parentId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getPriorityIcon(task.priority)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with Dependencies and Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Dependencies */}
|
||||
<div className="flex items-center">
|
||||
{task.dependencies && Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
|
||||
<Tooltip content={`Depends on: ${task.dependencies.map(dep => `Task ${dep}`).join(', ')}`}>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span>Depends on: {task.dependencies.join(', ')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Tooltip content={`Status: ${config.statusText}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.iconColor.replace('text-', 'bg-'))} />
|
||||
<span className={cn('text-xs font-medium', config.textColor)}>
|
||||
{config.statusText}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Subtask Progress (if applicable) */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
|
||||
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} of ${task.subtasks.length} subtasks completed`}>
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
task.status === 'done' ? 'bg-green-500' : 'bg-blue-500'
|
||||
)}
|
||||
style={{
|
||||
width: `${Math.round((task.subtasks.filter(st => st.status === 'done').length / task.subtasks.length) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} completed, ${task.subtasks.filter(st => st.status === 'pending').length} pending, ${task.subtasks.filter(st => st.status === 'in-progress').length} in progress`}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{task.subtasks.filter(st => st.status === 'done').length}/{task.subtasks.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCard;
|
||||
406
src/components/TaskDetail.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Flag, User, ArrowRight, CheckCircle, Circle, AlertCircle, Pause, Edit, Save, Copy, ChevronDown, ChevronRight, Clock } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
|
||||
const TaskDetail = ({
|
||||
task,
|
||||
onClose,
|
||||
onEdit,
|
||||
onStatusChange,
|
||||
onTaskClick,
|
||||
isOpen = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editedTask, setEditedTask] = useState(task || {});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTestStrategy, setShowTestStrategy] = useState(false);
|
||||
const { currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Only include changed fields
|
||||
const updates = {};
|
||||
if (editedTask.title !== task.title) updates.title = editedTask.title;
|
||||
if (editedTask.description !== task.description) updates.description = editedTask.description;
|
||||
if (editedTask.details !== task.details) updates.details = editedTask.details;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh tasks to get updated data
|
||||
refreshTasks?.();
|
||||
onEdit?.(editedTask);
|
||||
setEditMode(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to update task:', error);
|
||||
alert(`Failed to update task: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
setEditMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
alert('Error updating task. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: newStatus });
|
||||
|
||||
if (response.ok) {
|
||||
refreshTasks?.();
|
||||
onStatusChange?.(task.id, newStatus);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to update task status:', error);
|
||||
alert(`Failed to update task status: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task status:', error);
|
||||
alert('Error updating task status. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const copyTaskId = () => {
|
||||
navigator.clipboard.writeText(task.id.toString());
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return { icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-950' };
|
||||
case 'in-progress':
|
||||
return { icon: Clock, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950' };
|
||||
case 'review':
|
||||
return { icon: AlertCircle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950' };
|
||||
case 'deferred':
|
||||
return { icon: Pause, color: 'text-gray-500 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-800' };
|
||||
case 'cancelled':
|
||||
return { icon: X, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' };
|
||||
default:
|
||||
return { icon: Circle, color: 'text-slate-500 dark:text-slate-400', bg: 'bg-slate-50 dark:bg-slate-800' };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(task.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
|
||||
case 'medium': return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
|
||||
case 'low': return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
|
||||
default: return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in-progress', label: 'In Progress' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'deferred', label: 'Deferred' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<StatusIcon className={cn('w-6 h-6', statusConfig.color)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
onClick={copyTaskId}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Click to copy task ID"
|
||||
>
|
||||
<span>Task {task.id}</span>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
{task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Subtask of Task {task.parentId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedTask.title || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
|
||||
className="w-full text-lg font-semibold bg-transparent border-b-2 border-blue-500 focus:outline-none text-gray-900 dark:text-white"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-white line-clamp-2">
|
||||
{task.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={isSaving ? "Saving..." : "Save changes"}
|
||||
>
|
||||
<Save className={cn("w-5 h-5", isSaving && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditedTask(task);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Edit task"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 min-h-0">
|
||||
{/* Status and Metadata Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<div className={cn(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600',
|
||||
statusConfig.bg,
|
||||
statusConfig.color
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className="w-4 h-4" />
|
||||
<span className="font-medium capitalize">
|
||||
{statusOptions.find(option => option.value === task.status)?.label || task.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<div className={cn(
|
||||
'px-3 py-2 rounded-md text-sm font-medium capitalize',
|
||||
getPriorityColor(task.priority)
|
||||
)}>
|
||||
<Flag className="w-4 h-4 inline mr-2" />
|
||||
{task.priority || 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
|
||||
{task.dependencies && task.dependencies.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.dependencies.map(depId => (
|
||||
<button
|
||||
key={depId}
|
||||
onClick={() => onTaskClick && onTaskClick({ id: depId })}
|
||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-sm hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||
disabled={!onTaskClick}
|
||||
title={onTaskClick ? `Click to view Task ${depId}` : `Task ${depId}`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3 inline mr-1" />
|
||||
{depId}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">No dependencies</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedTask.description || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Task description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.description || 'No description provided'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Implementation Details */}
|
||||
{task.details && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Implementation Details
|
||||
</span>
|
||||
{showDetails ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedTask.details || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, details: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Implementation details"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Strategy */}
|
||||
{task.testStrategy && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowTestStrategy(!showTestStrategy)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Test Strategy
|
||||
</span>
|
||||
{showTestStrategy ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{showTestStrategy && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950 rounded-md p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.testStrategy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Subtasks ({task.subtasks.length})
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{task.subtasks.map(subtask => {
|
||||
const subtaskConfig = getStatusConfig(subtask.status);
|
||||
const SubtaskIcon = subtaskConfig.icon;
|
||||
return (
|
||||
<div key={subtask.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<SubtaskIcon className={cn('w-4 h-4', subtaskConfig.color)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{subtask.title}
|
||||
</h4>
|
||||
{subtask.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{subtask.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{subtask.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Task ID: {task.id}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetail;
|
||||
108
src/components/TaskIndicator.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, Settings, X, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* TaskIndicator Component
|
||||
*
|
||||
* Displays TaskMaster status for projects in the sidebar with appropriate
|
||||
* icons and colors based on the project's TaskMaster configuration state.
|
||||
*/
|
||||
const TaskIndicator = ({
|
||||
status = 'not-configured',
|
||||
size = 'sm',
|
||||
className = '',
|
||||
showLabel = false
|
||||
}) => {
|
||||
const getIndicatorConfig = () => {
|
||||
switch (status) {
|
||||
case 'fully-configured':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-500 dark:text-green-400',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
label: 'TaskMaster Ready',
|
||||
title: 'TaskMaster fully configured with MCP server'
|
||||
};
|
||||
|
||||
case 'taskmaster-only':
|
||||
return {
|
||||
icon: Settings,
|
||||
color: 'text-blue-500 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950',
|
||||
label: 'TaskMaster Init',
|
||||
title: 'TaskMaster initialized, MCP server needs setup'
|
||||
};
|
||||
|
||||
case 'mcp-only':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'text-amber-500 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950',
|
||||
label: 'MCP Ready',
|
||||
title: 'MCP server configured, TaskMaster needs initialization'
|
||||
};
|
||||
|
||||
case 'not-configured':
|
||||
case 'error':
|
||||
default:
|
||||
return {
|
||||
icon: X,
|
||||
color: 'text-gray-400 dark:text-gray-500',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-900',
|
||||
label: 'No TaskMaster',
|
||||
title: 'TaskMaster not configured'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getIndicatorConfig();
|
||||
const Icon = config.icon;
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6'
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
xs: 'p-0.5',
|
||||
sm: 'p-1',
|
||||
md: 'p-1.5',
|
||||
lg: 'p-2'
|
||||
};
|
||||
|
||||
if (showLabel) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors',
|
||||
config.bgColor,
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
title={config.title}
|
||||
>
|
||||
<Icon className={sizeClasses[size]} />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full transition-colors',
|
||||
config.bgColor,
|
||||
paddingClasses[size],
|
||||
className
|
||||
)}
|
||||
title={config.title}
|
||||
>
|
||||
<Icon className={cn(sizeClasses[size], config.color)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskIndicator;
|
||||
1054
src/components/TaskList.jsx
Normal file
603
src/components/TaskMasterSetupWizard.jsx
Normal file
@@ -0,0 +1,603 @@
|
||||
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';
|
||||
|
||||
const TaskMasterSetupWizard = ({
|
||||
isOpen = true,
|
||||
onClose,
|
||||
onComplete,
|
||||
currentProject,
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [setupData, setSetupData] = useState({
|
||||
projectRoot: '',
|
||||
initGit: true,
|
||||
storeTasksInGit: true,
|
||||
addAliases: true,
|
||||
skipInstall: false,
|
||||
rules: ['claude'],
|
||||
mcpConfigured: false,
|
||||
prdContent: ''
|
||||
});
|
||||
|
||||
const totalSteps = 4;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
setSetupData(prev => ({
|
||||
...prev,
|
||||
projectRoot: currentProject.path || ''
|
||||
}));
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Configuration',
|
||||
description: 'Configure basic TaskMaster settings for your project'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'MCP Server Setup',
|
||||
description: 'Ensure TaskMaster MCP server is properly configured'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'PRD Creation',
|
||||
description: 'Create or import a Product Requirements Document'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Complete Setup',
|
||||
description: 'Initialize TaskMaster and generate initial tasks'
|
||||
}
|
||||
];
|
||||
|
||||
const handleNext = async () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (currentStep === 1) {
|
||||
// Validate project configuration
|
||||
if (!setupData.projectRoot) {
|
||||
setError('Project root path is required');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
// Check MCP server status
|
||||
setLoading(true);
|
||||
try {
|
||||
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
|
||||
setSetupData(prev => ({
|
||||
...prev,
|
||||
mcpConfigured: mcpStatus.hasMCPServer && mcpStatus.isConfigured
|
||||
}));
|
||||
setCurrentStep(3);
|
||||
} catch (err) {
|
||||
setError('Failed to check MCP server status. You can continue but some features may not work.');
|
||||
setCurrentStep(3);
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
// Validate PRD step
|
||||
if (!setupData.prdContent.trim()) {
|
||||
setError('Please create or import a PRD to continue');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(4);
|
||||
} else if (currentStep === 4) {
|
||||
// Complete setup
|
||||
await completeSetup();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const completeSetup = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Initialize TaskMaster project
|
||||
const initResponse = await api.post('/taskmaster/initialize', {
|
||||
projectRoot: setupData.projectRoot,
|
||||
initGit: setupData.initGit,
|
||||
storeTasksInGit: setupData.storeTasksInGit,
|
||||
addAliases: setupData.addAliases,
|
||||
skipInstall: setupData.skipInstall,
|
||||
rules: setupData.rules,
|
||||
yes: true
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error('Failed to initialize TaskMaster project');
|
||||
}
|
||||
|
||||
// Save PRD content if provided
|
||||
if (setupData.prdContent.trim()) {
|
||||
const prdResponse = await api.post('/taskmaster/save-prd', {
|
||||
projectRoot: setupData.projectRoot,
|
||||
content: setupData.prdContent
|
||||
});
|
||||
|
||||
if (!prdResponse.ok) {
|
||||
console.warn('Failed to save PRD content');
|
||||
}
|
||||
}
|
||||
|
||||
// Parse PRD to generate initial tasks
|
||||
if (setupData.prdContent.trim()) {
|
||||
const parseResponse = await api.post('/taskmaster/parse-prd', {
|
||||
projectRoot: setupData.projectRoot,
|
||||
input: '.taskmaster/docs/prd.txt',
|
||||
numTasks: '10',
|
||||
research: false,
|
||||
force: false
|
||||
});
|
||||
|
||||
if (!parseResponse.ok) {
|
||||
console.warn('Failed to parse PRD and generate tasks');
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to complete TaskMaster setup');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyMCPConfig = () => {
|
||||
const mcpConfig = `{
|
||||
"mcpServers": {
|
||||
"": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
|
||||
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
navigator.clipboard.writeText(mcpConfig);
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Settings className="w-12 h-12 text-blue-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Project Configuration
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Configure TaskMaster settings for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Root Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={setupData.projectRoot}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, projectRoot: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="/path/to/your/project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Options</h4>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.initGit}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, initGit: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Initialize Git repository</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.storeTasksInGit}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, storeTasksInGit: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Store tasks in Git</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.addAliases}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, addAliases: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Add shell aliases (tm, taskmaster)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rule Profiles
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['claude', 'cursor', 'vscode', 'roo', 'cline', 'windsurf'].map(rule => (
|
||||
<label key={rule} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.rules.includes(rule)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSetupData(prev => ({ ...prev, rules: [...prev.rules, rule] }));
|
||||
} else {
|
||||
setSetupData(prev => ({ ...prev, rules: prev.rules.filter(r => r !== rule) }));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">{rule}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Server className="w-12 h-12 text-purple-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
MCP Server Setup
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
TaskMaster works best with the MCP server configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
MCP Server Configuration
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
To enable full TaskMaster integration, add the MCP server configuration to your Claude settings.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border p-3 mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-mono text-gray-600 dark:text-gray-400">.mcp.json</span>
|
||||
<button
|
||||
onClick={copyMCPConfig}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
|
||||
{`{
|
||||
"mcpServers": {
|
||||
"task-master-ai": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
|
||||
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href="https://docs.anthropic.com/en/docs/build-with-claude/tool-use/mcp-servers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-1"
|
||||
>
|
||||
Learn about MCP setup
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Current Status</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{setupData.mcpConfigured ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300">MCP server is configured</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm text-amber-700 dark:text-amber-300">MCP server not detected (optional)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<FileText className="w-12 h-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Product Requirements Document
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Create or import a PRD to generate initial tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
PRD Content
|
||||
</label>
|
||||
<textarea
|
||||
value={setupData.prdContent}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, prdContent: e.target.value }))}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="# Product Requirements Document
|
||||
|
||||
## 1. Overview
|
||||
Describe your project or feature...
|
||||
|
||||
## 2. Objectives
|
||||
- Primary goal
|
||||
- Success metrics
|
||||
|
||||
## 3. User Stories
|
||||
- As a user, I want...
|
||||
|
||||
## 4. Requirements
|
||||
- Feature requirements
|
||||
- Technical requirements
|
||||
|
||||
## 5. Implementation Plan
|
||||
- Phase 1: Core features
|
||||
- Phase 2: Enhancements"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
AI Task Generation
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
TaskMaster will analyze your PRD and automatically generate a structured task list with dependencies, priorities, and implementation details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Complete Setup
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Ready to initialize TaskMaster for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100 mb-3">
|
||||
Setup Summary
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-green-800 dark:text-green-200">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Project: {setupData.projectRoot}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Rules: {setupData.rules.join(', ')}
|
||||
</li>
|
||||
{setupData.mcpConfigured && (
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
MCP server configured
|
||||
</li>
|
||||
)}
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
PRD content ready ({setupData.prdContent.length} characters)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
What happens next?
|
||||
</h4>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>Initialize TaskMaster project structure</li>
|
||||
<li>Save your PRD to <code>.taskmaster/docs/prd.txt</code></li>
|
||||
<li>Generate initial tasks from your PRD</li>
|
||||
<li>Set up project configuration and rules</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
TaskMaster Setup Wizard
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Step {currentStep} of {totalSteps}: {steps[currentStep - 1]?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-4 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors',
|
||||
currentStep > step.id
|
||||
? 'bg-green-500 text-white'
|
||||
: currentStep === step.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
)}>
|
||||
{currentStep > step.id ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
step.id
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
'w-16 h-1 mx-2 rounded',
|
||||
currentStep > step.id
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
{steps.map(step => (
|
||||
<span key={step.id} className="text-center">
|
||||
{step.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{renderStepContent()}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-red-900 dark:text-red-100 mb-1">Error</h4>
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{currentStep} of {totalSteps}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{currentStep === totalSteps ? 'Setting up...' : 'Processing...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === totalSteps ? 'Complete Setup' : 'Next'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterSetupWizard;
|
||||
86
src/components/TaskMasterStatus.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
|
||||
const TaskMasterStatus = () => {
|
||||
const {
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
isLoading,
|
||||
isLoadingMCP,
|
||||
error
|
||||
} = useTaskMaster();
|
||||
|
||||
if (isLoading || isLoadingMCP) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="animate-spin w-3 h-3 border border-gray-300 border-t-blue-500 rounded-full mr-2"></div>
|
||||
Loading TaskMaster status...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-red-500 dark:text-red-400">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
TaskMaster Error
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show MCP server status
|
||||
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
|
||||
|
||||
// Show project TaskMaster status
|
||||
const projectConfigured = currentProject?.taskmaster?.hasTaskmaster;
|
||||
const taskCount = currentProject?.taskmaster?.metadata?.taskCount || 0;
|
||||
const completedCount = currentProject?.taskmaster?.metadata?.completed || 0;
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
|
||||
No project selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine overall status for TaskIndicator
|
||||
let overallStatus = 'not-configured';
|
||||
if (projectConfigured && mcpConfigured) {
|
||||
overallStatus = 'fully-configured';
|
||||
} else if (projectConfigured) {
|
||||
overallStatus = 'taskmaster-only';
|
||||
} else if (mcpConfigured) {
|
||||
overallStatus = 'mcp-only';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* TaskMaster Status Indicator */}
|
||||
<TaskIndicator
|
||||
status={overallStatus}
|
||||
size="md"
|
||||
showLabel={true}
|
||||
/>
|
||||
|
||||
{/* Task Progress Info */}
|
||||
{projectConfigured && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">
|
||||
{completedCount}/{taskCount} tasks
|
||||
</span>
|
||||
{taskCount > 0 && (
|
||||
<span className="ml-2 opacity-75">
|
||||
({Math.round((completedCount / taskCount) * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterStatus;
|
||||
107
src/components/TasksSettings.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
function TasksSettings() {
|
||||
const {
|
||||
tasksEnabled,
|
||||
setTasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
isCheckingInstallation
|
||||
} = useTasksSettings();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Installation Status Check */}
|
||||
{isCheckingInstallation ? (
|
||||
<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 gap-3">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm text-muted-foreground">Checking TaskMaster installation...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TaskMaster Not Installed Warning */}
|
||||
{!isTaskMasterInstalled && (
|
||||
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
|
||||
TaskMaster AI CLI Not Installed
|
||||
</div>
|
||||
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
|
||||
<p>TaskMaster CLI is required to use task management features. Install it to get started:</p>
|
||||
|
||||
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
|
||||
<code>npm install -g task-master-ai</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
|
||||
>
|
||||
<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
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">After installation:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-xs">
|
||||
<li>Restart this application</li>
|
||||
<li>TaskMaster features will automatically become available</li>
|
||||
<li>Use <code className="bg-orange-100 dark:bg-orange-800 px-1 rounded">task-master init</code> in your project directory</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TaskMaster Settings */}
|
||||
{isTaskMasterInstalled && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">
|
||||
Enable TaskMaster Integration
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
Show TaskMaster tasks, banners, and sidebar indicators across the interface
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tasksEnabled}
|
||||
onChange={(e) => setTasksEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TasksSettings;
|
||||
@@ -51,9 +51,9 @@ const TodoList = ({ todos, isResult = false }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todos.map((todo) => (
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
key={todo.id || `todo-${index}`}
|
||||
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md dark:shadow-gray-900/50 transition-shadow"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
|
||||
53
src/components/TokenUsagePie.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
function TokenUsagePie({ used, total }) {
|
||||
// Token usage visualization component
|
||||
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
|
||||
if (used == null || total == null || total <= 0) return null;
|
||||
|
||||
const percentage = Math.min(100, (used / total) * 100);
|
||||
const radius = 10;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
// Color based on usage level
|
||||
const getColor = () => {
|
||||
if (percentage < 50) return '#3b82f6'; // blue
|
||||
if (percentage < 75) return '#f59e0b'; // orange
|
||||
return '#ef4444'; // red
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" className="transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={getColor()}
|
||||
strokeWidth="2"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsagePie;
|
||||
91
src/components/Tooltip.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
content,
|
||||
position = 'top',
|
||||
className = '',
|
||||
delay = 500
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [timeoutId, setTimeoutId] = useState(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const id = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
setTimeoutId(id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
setTimeoutId(null);
|
||||
}
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const getPositionClasses = () => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
|
||||
case 'bottom':
|
||||
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
|
||||
case 'left':
|
||||
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
|
||||
case 'right':
|
||||
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
|
||||
default:
|
||||
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
|
||||
}
|
||||
};
|
||||
|
||||
const getArrowClasses = () => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
|
||||
case 'bottom':
|
||||
return 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100';
|
||||
case 'left':
|
||||
return 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100';
|
||||
case 'right':
|
||||
return 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100';
|
||||
default:
|
||||
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
if (!content) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
{isVisible && (
|
||||
<div className={cn(
|
||||
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-200',
|
||||
getPositionClasses(),
|
||||
className
|
||||
)}>
|
||||
{content}
|
||||
|
||||
{/* Arrow */}
|
||||
<div className={cn(
|
||||
'absolute w-0 h-0 border-4 border-transparent',
|
||||
getArrowClasses()
|
||||
)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
124
src/components/settings/AccountContent.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import ClaudeLogo from '../ClaudeLogo';
|
||||
import CursorLogo from '../CursorLogo';
|
||||
import CodexLogo from '../CodexLogo';
|
||||
|
||||
const agentConfig = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
description: 'Anthropic Claude AI assistant',
|
||||
Logo: ClaudeLogo,
|
||||
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderClass: 'border-blue-200 dark:border-blue-800',
|
||||
textClass: 'text-blue-900 dark:text-blue-100',
|
||||
subtextClass: 'text-blue-700 dark:text-blue-300',
|
||||
buttonClass: 'bg-blue-600 hover:bg-blue-700',
|
||||
},
|
||||
cursor: {
|
||||
name: 'Cursor',
|
||||
description: 'Cursor AI-powered code editor',
|
||||
Logo: CursorLogo,
|
||||
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
borderClass: 'border-purple-200 dark:border-purple-800',
|
||||
textClass: 'text-purple-900 dark:text-purple-100',
|
||||
subtextClass: 'text-purple-700 dark:text-purple-300',
|
||||
buttonClass: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
codex: {
|
||||
name: 'Codex',
|
||||
description: 'OpenAI Codex AI assistant',
|
||||
Logo: CodexLogo,
|
||||
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
|
||||
borderClass: 'border-gray-300 dark:border-gray-600',
|
||||
textClass: 'text-gray-900 dark:text-gray-100',
|
||||
subtextClass: 'text-gray-700 dark:text-gray-300',
|
||||
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AccountContent({ agent, authStatus, onLogin }) {
|
||||
const config = agentConfig[agent];
|
||||
const { Logo } = config;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Logo className="w-6 h-6" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground">{config.name} Account</h3>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
|
||||
<div className="space-y-4">
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${config.textClass}`}>
|
||||
Connection Status
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus?.loading ? (
|
||||
'Checking authentication status...'
|
||||
) : authStatus?.authenticated ? (
|
||||
`Logged in as ${authStatus.email || 'authenticated user'}`
|
||||
) : (
|
||||
'Not connected'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{authStatus?.loading ? (
|
||||
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
|
||||
Checking...
|
||||
</Badge>
|
||||
) : authStatus?.authenticated ? (
|
||||
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className={`font-medium ${config.textClass}`}>
|
||||
{authStatus?.authenticated ? 'Re-authenticate' : 'Login'}
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus?.authenticated
|
||||
? 'Sign in with a different account or refresh credentials'
|
||||
: `Sign in to your ${config.name} account to enable AI features`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
className={`${config.buttonClass} text-white`}
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
{authStatus?.authenticated ? 'Re-login' : 'Login'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authStatus?.error && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
Error: {authStatus.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/settings/AgentListItem.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import ClaudeLogo from '../ClaudeLogo';
|
||||
import CursorLogo from '../CursorLogo';
|
||||
import CodexLogo from '../CodexLogo';
|
||||
|
||||
const agentConfig = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
color: 'blue',
|
||||
Logo: ClaudeLogo,
|
||||
},
|
||||
cursor: {
|
||||
name: 'Cursor',
|
||||
color: 'purple',
|
||||
Logo: CursorLogo,
|
||||
},
|
||||
codex: {
|
||||
name: 'Codex',
|
||||
color: 'gray',
|
||||
Logo: CodexLogo,
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
blue: {
|
||||
border: 'border-l-blue-500 md:border-l-blue-500',
|
||||
borderBottom: 'border-b-blue-500',
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
dot: 'bg-blue-500',
|
||||
},
|
||||
purple: {
|
||||
border: 'border-l-purple-500 md:border-l-purple-500',
|
||||
borderBottom: 'border-b-purple-500',
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
dot: 'bg-purple-500',
|
||||
},
|
||||
gray: {
|
||||
border: 'border-l-gray-700 dark:border-l-gray-300',
|
||||
borderBottom: 'border-b-gray-700 dark:border-b-gray-300',
|
||||
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
||||
dot: 'bg-gray-700 dark:bg-gray-300',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AgentListItem({ agentId, authStatus, isSelected, onClick, isMobile = false }) {
|
||||
const config = agentConfig[agentId];
|
||||
const colors = colorClasses[config.color];
|
||||
const { Logo } = config;
|
||||
|
||||
// Mobile: horizontal layout with bottom border
|
||||
if (isMobile) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${
|
||||
isSelected
|
||||
? `${colors.borderBottom} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="w-5 h-5" />
|
||||
<span className="text-xs font-medium text-foreground">{config.name}</span>
|
||||
{authStatus?.authenticated && (
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: vertical layout with left border
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-3 border-l-4 transition-colors ${
|
||||
isSelected
|
||||
? `${colors.border} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Logo className="w-4 h-4" />
|
||||
<span className="font-medium text-foreground">{config.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground pl-6">
|
||||
{authStatus?.loading ? (
|
||||
<span className="text-gray-400">Checking...</span>
|
||||
) : authStatus?.authenticated ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
<span className="truncate max-w-[120px]" title={authStatus.email}>
|
||||
{authStatus.email || 'Connected'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
|
||||
<span>Not connected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
314
src/components/settings/McpServersContent.jsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Server, Plus, Edit3, Trash2, Terminal, Globe, Zap, X } from 'lucide-react';
|
||||
|
||||
const getTransportIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'stdio': return <Terminal className="w-4 h-4" />;
|
||||
case 'sse': return <Zap className="w-4 h-4" />;
|
||||
case 'http': return <Globe className="w-4 h-4" />;
|
||||
default: return <Server className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Claude MCP Servers
|
||||
function ClaudeMcpServers({
|
||||
servers,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
onDiscoverTools,
|
||||
testResults,
|
||||
serverTools,
|
||||
toolsLoading,
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
MCP Servers
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model Context Protocol servers provide additional tools and data sources to Claude
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map(server => (
|
||||
<div key={server.id} 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-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getTransportIcon(server.type)}
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.scope === 'local' ? 'local' : server.scope === 'user' ? 'user' : server.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{server.type === 'stdio' && server.config?.command && (
|
||||
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||
)}
|
||||
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
||||
<div>URL: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
|
||||
)}
|
||||
{server.config?.args && server.config.args.length > 0 && (
|
||||
<div>Args: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults?.[server.id] && (
|
||||
<div className={`mt-2 p-2 rounded text-xs ${
|
||||
testResults[server.id].success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
<div className="font-medium">{testResults[server.id].message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Discovery Results */}
|
||||
{serverTools?.[server.id] && serverTools[server.id].tools?.length > 0 && (
|
||||
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
|
||||
<div className="font-medium">Tools ({serverTools[server.id].tools.length}):</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{serverTools[server.id].tools.slice(0, 5).map((tool, i) => (
|
||||
<code key={i} className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{tool.name}</code>
|
||||
))}
|
||||
{serverTools[server.id].tools.length > 5 && (
|
||||
<span className="text-xs opacity-75">+{serverTools[server.id].tools.length - 5} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title="Edit server"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.id, server.scope)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Delete server"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor MCP Servers
|
||||
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
MCP Servers
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model Context Protocol servers provide additional tools and data sources to Cursor
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map(server => (
|
||||
<div key={server.name || server.id} 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-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{server.config?.command && (
|
||||
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex MCP Servers
|
||||
function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
MCP Servers
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model Context Protocol servers provide additional tools and data sources to Codex
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map(server => (
|
||||
<div key={server.name} 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-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{server.config?.command && (
|
||||
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||
)}
|
||||
{server.config?.args && server.config.args.length > 0 && (
|
||||
<div>Args: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
||||
)}
|
||||
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>Environment: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}</code></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title="Edit server"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Delete server"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">About Codex MCP</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities
|
||||
with additional tools and resources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function McpServersContent({ agent, ...props }) {
|
||||
if (agent === 'claude') {
|
||||
return <ClaudeMcpServers {...props} />;
|
||||
}
|
||||
if (agent === 'cursor') {
|
||||
return <CursorMcpServers {...props} />;
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return <CodexMcpServers {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
611
src/components/settings/PermissionsContent.jsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Shield, AlertTriangle, Plus, X } from 'lucide-react';
|
||||
|
||||
// Common tool patterns for Claude
|
||||
const commonClaudeTools = [
|
||||
'Bash(git log:*)',
|
||||
'Bash(git diff:*)',
|
||||
'Bash(git status:*)',
|
||||
'Write',
|
||||
'Read',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'MultiEdit',
|
||||
'Task',
|
||||
'TodoWrite',
|
||||
'TodoRead',
|
||||
'WebFetch',
|
||||
'WebSearch'
|
||||
];
|
||||
|
||||
// Common shell commands for Cursor
|
||||
const commonCursorCommands = [
|
||||
'Shell(ls)',
|
||||
'Shell(mkdir)',
|
||||
'Shell(cd)',
|
||||
'Shell(cat)',
|
||||
'Shell(echo)',
|
||||
'Shell(git status)',
|
||||
'Shell(git diff)',
|
||||
'Shell(git log)',
|
||||
'Shell(npm install)',
|
||||
'Shell(npm run)',
|
||||
'Shell(python)',
|
||||
'Shell(node)'
|
||||
];
|
||||
|
||||
// Claude Permissions
|
||||
function ClaudePermissions({
|
||||
skipPermissions,
|
||||
setSkipPermissions,
|
||||
allowedTools,
|
||||
setAllowedTools,
|
||||
disallowedTools,
|
||||
setDisallowedTools,
|
||||
newAllowedTool,
|
||||
setNewAllowedTool,
|
||||
newDisallowedTool,
|
||||
setNewDisallowedTool,
|
||||
}) {
|
||||
const addAllowedTool = (tool) => {
|
||||
if (tool && !allowedTools.includes(tool)) {
|
||||
setAllowedTools([...allowedTools, tool]);
|
||||
setNewAllowedTool('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAllowedTool = (tool) => {
|
||||
setAllowedTools(allowedTools.filter(t => t !== tool));
|
||||
};
|
||||
|
||||
const addDisallowedTool = (tool) => {
|
||||
if (tool && !disallowedTools.includes(tool)) {
|
||||
setDisallowedTools([...disallowedTools, tool]);
|
||||
setNewDisallowedTool('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeDisallowedTool = (tool) => {
|
||||
setDisallowedTools(disallowedTools.filter(t => t !== tool));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip Permissions */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Permission Settings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipPermissions}
|
||||
onChange={(e) => setSkipPermissions(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100">
|
||||
Skip permission prompts (use with caution)
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Equivalent to --dangerously-skip-permissions flag
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Tools */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Allowed Tools
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tools that are automatically allowed without prompting for permission
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newAllowedTool}
|
||||
onChange={(e) => setNewAllowedTool(e.target.value)}
|
||||
placeholder='e.g., "Bash(git log:*)" or "Write"'
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAllowedTool(newAllowedTool);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addAllowedTool(newAllowedTool)}
|
||||
disabled={!newAllowedTool}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick add buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Quick add common tools:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{commonClaudeTools.map(tool => (
|
||||
<Button
|
||||
key={tool}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAllowedTool(tool)}
|
||||
disabled={allowedTools.includes(tool)}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
{tool}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allowedTools.map(tool => (
|
||||
<div key={tool} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-green-800 dark:text-green-200">
|
||||
{tool}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAllowedTool(tool)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{allowedTools.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
No allowed tools configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disallowed Tools */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Blocked Tools
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tools that are automatically blocked without prompting for permission
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newDisallowedTool}
|
||||
onChange={(e) => setNewDisallowedTool(e.target.value)}
|
||||
placeholder='e.g., "Bash(rm:*)"'
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addDisallowedTool(newDisallowedTool);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addDisallowedTool(newDisallowedTool)}
|
||||
disabled={!newDisallowedTool}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{disallowedTools.map(tool => (
|
||||
<div key={tool} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-red-800 dark:text-red-200">
|
||||
{tool}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisallowedTool(tool)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{disallowedTools.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
No blocked tools configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
Tool Pattern Examples:
|
||||
</h4>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> - Allow all git log commands</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> - Allow all git diff commands</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> - Allow all Write tool usage</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor Permissions
|
||||
function CursorPermissions({
|
||||
skipPermissions,
|
||||
setSkipPermissions,
|
||||
allowedCommands,
|
||||
setAllowedCommands,
|
||||
disallowedCommands,
|
||||
setDisallowedCommands,
|
||||
newAllowedCommand,
|
||||
setNewAllowedCommand,
|
||||
newDisallowedCommand,
|
||||
setNewDisallowedCommand,
|
||||
}) {
|
||||
const addAllowedCommand = (cmd) => {
|
||||
if (cmd && !allowedCommands.includes(cmd)) {
|
||||
setAllowedCommands([...allowedCommands, cmd]);
|
||||
setNewAllowedCommand('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAllowedCommand = (cmd) => {
|
||||
setAllowedCommands(allowedCommands.filter(c => c !== cmd));
|
||||
};
|
||||
|
||||
const addDisallowedCommand = (cmd) => {
|
||||
if (cmd && !disallowedCommands.includes(cmd)) {
|
||||
setDisallowedCommands([...disallowedCommands, cmd]);
|
||||
setNewDisallowedCommand('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeDisallowedCommand = (cmd) => {
|
||||
setDisallowedCommands(disallowedCommands.filter(c => c !== cmd));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip Permissions */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Permission Settings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipPermissions}
|
||||
onChange={(e) => setSkipPermissions(e.target.checked)}
|
||||
className="w-4 h-4 text-purple-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-purple-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100">
|
||||
Skip permission prompts (use with caution)
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Equivalent to -f flag in Cursor CLI
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Commands */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Allowed Shell Commands
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shell commands that are automatically allowed without prompting
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newAllowedCommand}
|
||||
onChange={(e) => setNewAllowedCommand(e.target.value)}
|
||||
placeholder='e.g., "Shell(ls)" or "Shell(git status)"'
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAllowedCommand(newAllowedCommand);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addAllowedCommand(newAllowedCommand)}
|
||||
disabled={!newAllowedCommand}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick add buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Quick add common commands:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{commonCursorCommands.map(cmd => (
|
||||
<Button
|
||||
key={cmd}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAllowedCommand(cmd)}
|
||||
disabled={allowedCommands.includes(cmd)}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
{cmd}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allowedCommands.map(cmd => (
|
||||
<div key={cmd} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-green-800 dark:text-green-200">
|
||||
{cmd}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAllowedCommand(cmd)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{allowedCommands.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
No allowed commands configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disallowed Commands */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Blocked Shell Commands
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shell commands that are automatically blocked
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newDisallowedCommand}
|
||||
onChange={(e) => setNewDisallowedCommand(e.target.value)}
|
||||
placeholder='e.g., "Shell(rm -rf)" or "Shell(sudo)"'
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addDisallowedCommand(newDisallowedCommand);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addDisallowedCommand(newDisallowedCommand)}
|
||||
disabled={!newDisallowedCommand}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{disallowedCommands.map(cmd => (
|
||||
<div key={cmd} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-red-800 dark:text-red-200">
|
||||
{cmd}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisallowedCommand(cmd)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{disallowedCommands.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
No blocked commands configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-purple-900 dark:text-purple-100 mb-2">
|
||||
Shell Command Examples:
|
||||
</h4>
|
||||
<ul className="text-sm text-purple-800 dark:text-purple-200 space-y-1">
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(ls)"</code> - Allow ls command</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> - Allow git status</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(npm install)"</code> - Allow npm install</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(rm -rf)"</code> - Block recursive delete</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex Permissions
|
||||
function CodexPermissions({ permissionMode, setPermissionMode }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Permission Mode
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controls how Codex handles file modifications and command execution
|
||||
</p>
|
||||
|
||||
{/* Default Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setPermissionMode('default')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="codexPermissionMode"
|
||||
checked={permissionMode === 'default'}
|
||||
onChange={() => setPermissionMode('default')}
|
||||
className="mt-1 w-4 h-4 text-green-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Default</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Only trusted commands (ls, cat, grep, git status, etc.) run automatically.
|
||||
Other commands are skipped. Can write to workspace.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Accept Edits Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'acceptEdits'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setPermissionMode('acceptEdits')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="codexPermissionMode"
|
||||
checked={permissionMode === 'acceptEdits'}
|
||||
onChange={() => setPermissionMode('acceptEdits')}
|
||||
className="mt-1 w-4 h-4 text-green-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-green-900 dark:text-green-100">Accept Edits</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-300">
|
||||
All commands run automatically within the workspace.
|
||||
Full auto mode with sandboxed execution.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Bypass Permissions Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setPermissionMode('bypassPermissions')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="codexPermissionMode"
|
||||
checked={permissionMode === 'bypassPermissions'}
|
||||
onChange={() => setPermissionMode('bypassPermissions')}
|
||||
className="mt-1 w-4 h-4 text-orange-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 flex items-center gap-2">
|
||||
Bypass Permissions
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Full system access with no restrictions. All commands run automatically
|
||||
with full disk and network access. Use with caution.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Technical details
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-xs text-muted-foreground space-y-2">
|
||||
<p><strong>Default:</strong> sandboxMode=workspace-write, approvalPolicy=untrusted. Trusted commands: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (without -exec), etc.</p>
|
||||
<p><strong>Accept Edits:</strong> sandboxMode=workspace-write, approvalPolicy=never. All commands auto-execute within project directory.</p>
|
||||
<p><strong>Bypass Permissions:</strong> sandboxMode=danger-full-access, approvalPolicy=never. Full system access, use only in trusted environments.</p>
|
||||
<p className="text-xs opacity-75">You can override this per-session using the mode button in the chat interface.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function PermissionsContent({ agent, ...props }) {
|
||||
if (agent === 'claude') {
|
||||
return <ClaudePermissions {...props} />;
|
||||
}
|
||||
if (agent === 'cursor') {
|
||||
return <CursorPermissions {...props} />;
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return <CodexPermissions {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const AuthContext = createContext({
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
needsSetup: false,
|
||||
hasCompletedOnboarding: true,
|
||||
refreshOnboardingStatus: () => {},
|
||||
error: null
|
||||
});
|
||||
|
||||
@@ -25,37 +27,63 @@ export const AuthProvider = ({ children }) => {
|
||||
const [token, setToken] = useState(localStorage.getItem('auth-token'));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
setUser({ username: 'platform-user' });
|
||||
setNeedsSetup(false);
|
||||
checkOnboardingStatus();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const response = await api.user.onboardingStatus();
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHasCompletedOnboarding(data.hasCompletedOnboarding);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking onboarding status:', error);
|
||||
setHasCompletedOnboarding(true);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOnboardingStatus = async () => {
|
||||
await checkOnboardingStatus();
|
||||
};
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
// Check if system needs setup
|
||||
const statusResponse = await api.auth.status();
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
|
||||
if (statusData.needsSetup) {
|
||||
setNeedsSetup(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// If we have a token, verify it
|
||||
if (token) {
|
||||
try {
|
||||
const userResponse = await api.auth.user();
|
||||
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
setUser(userData.user);
|
||||
setNeedsSetup(false);
|
||||
await checkOnboardingStatus();
|
||||
} else {
|
||||
// Token is invalid
|
||||
localStorage.removeItem('auth-token');
|
||||
@@ -70,7 +98,7 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth status check failed:', error);
|
||||
console.error('[AuthContext] Auth status check failed:', error);
|
||||
setError('Failed to check authentication status');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -147,6 +175,8 @@ export const AuthProvider = ({ children }) => {
|
||||
logout,
|
||||
isLoading,
|
||||
needsSetup,
|
||||
hasCompletedOnboarding,
|
||||
refreshOnboardingStatus,
|
||||
error
|
||||
};
|
||||
|
||||
|
||||
302
src/contexts/TaskMasterContext.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
|
||||
const TaskMasterContext = createContext({
|
||||
// TaskMaster project state
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
projectTaskMaster: null,
|
||||
|
||||
// MCP server state
|
||||
mcpServerStatus: null,
|
||||
|
||||
// Tasks state
|
||||
tasks: [],
|
||||
nextTask: null,
|
||||
|
||||
// Loading states
|
||||
isLoading: false,
|
||||
isLoadingTasks: false,
|
||||
isLoadingMCP: false,
|
||||
|
||||
// Error state
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
refreshProjects: () => {},
|
||||
setCurrentProject: () => {},
|
||||
refreshTasks: () => {},
|
||||
refreshMCPStatus: () => {},
|
||||
clearError: () => {}
|
||||
});
|
||||
|
||||
export const useTaskMaster = () => {
|
||||
const context = useContext(TaskMasterContext);
|
||||
if (!context) {
|
||||
throw new Error('useTaskMaster must be used within a TaskMasterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const TaskMasterProvider = ({ children }) => {
|
||||
// Get WebSocket messages from shared context to avoid duplicate connections
|
||||
const { messages } = useWebSocketContext();
|
||||
|
||||
// Authentication context
|
||||
const { user, token, isLoading: authLoading } = useAuth();
|
||||
|
||||
// State
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [currentProject, setCurrentProjectState] = useState(null);
|
||||
const [projectTaskMaster, setProjectTaskMaster] = useState(null);
|
||||
const [mcpServerStatus, setMCPServerStatus] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [nextTask, setNextTask] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Helper to handle API errors
|
||||
const handleError = (error, context) => {
|
||||
console.error(`TaskMaster ${context} error:`, error);
|
||||
setError({
|
||||
message: error.message || `Failed to ${context}`,
|
||||
context,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// This will be defined after the functions are declared
|
||||
|
||||
// Refresh projects with TaskMaster metadata
|
||||
const refreshProjects = useCallback(async () => {
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setProjects([]);
|
||||
setCurrentProjectState(null); // This might be the problem!
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
const response = await api.get('/projects');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`);
|
||||
}
|
||||
|
||||
const projectsData = await response.json();
|
||||
|
||||
// Check if projectsData is an array
|
||||
if (!Array.isArray(projectsData)) {
|
||||
console.error('Projects API returned non-array data:', projectsData);
|
||||
setProjects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter and enrich projects with TaskMaster data
|
||||
const enrichedProjects = projectsData.map(project => ({
|
||||
...project,
|
||||
taskMasterConfigured: project.taskmaster?.hasTaskmaster || false,
|
||||
taskMasterStatus: project.taskmaster?.status || 'not-configured',
|
||||
taskCount: project.taskmaster?.metadata?.taskCount || 0,
|
||||
completedCount: project.taskmaster?.metadata?.completed || 0
|
||||
}));
|
||||
|
||||
setProjects(enrichedProjects);
|
||||
|
||||
// If current project is set, update its TaskMaster data
|
||||
if (currentProject) {
|
||||
const updatedCurrent = enrichedProjects.find(p => p.name === currentProject.name);
|
||||
if (updatedCurrent) {
|
||||
setCurrentProjectState(updatedCurrent);
|
||||
setProjectTaskMaster(updatedCurrent.taskmaster);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'load projects');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, token]); // Remove currentProject dependency to avoid infinite loops
|
||||
|
||||
// Set current project and load its TaskMaster details
|
||||
const setCurrentProject = useCallback(async (project) => {
|
||||
try {
|
||||
setCurrentProjectState(project);
|
||||
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
} catch (err) {
|
||||
console.error('Error in setCurrentProject:', err);
|
||||
handleError(err, 'set current project');
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh MCP server status
|
||||
const refreshMCPStatus = useCallback(async () => {
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setMCPServerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMCP(true);
|
||||
clearError();
|
||||
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
|
||||
setMCPServerStatus(mcpStatus);
|
||||
} catch (err) {
|
||||
handleError(err, 'check MCP server status');
|
||||
} finally {
|
||||
setIsLoadingMCP(false);
|
||||
}
|
||||
}, [user, token]);
|
||||
|
||||
// Refresh tasks for current project - load real TaskMaster data
|
||||
const refreshTasks = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingTasks(true);
|
||||
clearError();
|
||||
|
||||
// Load tasks from the TaskMaster API endpoint
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(currentProject.name)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to load tasks');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setTasks(data.tasks || []);
|
||||
|
||||
// Find next task (pending or in-progress)
|
||||
const nextTask = data.tasks?.find(task =>
|
||||
task.status === 'pending' || task.status === 'in-progress'
|
||||
) || null;
|
||||
setNextTask(nextTask);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading tasks:', err);
|
||||
handleError(err, 'load tasks');
|
||||
// Set empty state on error
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
}, [currentProject, user, token]);
|
||||
|
||||
// Load initial data on mount or when auth changes
|
||||
useEffect(() => {
|
||||
if (!authLoading && user && token) {
|
||||
refreshProjects();
|
||||
refreshMCPStatus();
|
||||
} else {
|
||||
console.log('Auth not ready or no user, skipping project load:', { authLoading, user: !!user, token: !!token });
|
||||
}
|
||||
}, [refreshProjects, refreshMCPStatus, authLoading, user, token]);
|
||||
|
||||
// Clear errors when authentication changes
|
||||
useEffect(() => {
|
||||
if (user && token) {
|
||||
clearError();
|
||||
}
|
||||
}, [user, token, clearError]);
|
||||
|
||||
// Refresh tasks when current project changes
|
||||
useEffect(() => {
|
||||
if (currentProject?.name && user && token) {
|
||||
refreshTasks();
|
||||
}
|
||||
}, [currentProject?.name, user, token, refreshTasks]);
|
||||
|
||||
// Handle WebSocket messages for TaskMaster updates
|
||||
useEffect(() => {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
if (!latestMessage) return;
|
||||
|
||||
|
||||
switch (latestMessage.type) {
|
||||
case 'taskmaster-project-updated':
|
||||
// Refresh projects when TaskMaster state changes
|
||||
if (latestMessage.projectName) {
|
||||
refreshProjects();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'taskmaster-tasks-updated':
|
||||
// Refresh tasks for the current project
|
||||
if (latestMessage.projectName === currentProject?.name) {
|
||||
refreshTasks();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'taskmaster-mcp-status-changed':
|
||||
// Refresh MCP server status
|
||||
refreshMCPStatus();
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore non-TaskMaster messages
|
||||
break;
|
||||
}
|
||||
}, [messages, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
|
||||
// Context value
|
||||
const contextValue = {
|
||||
// State
|
||||
projects,
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
tasks,
|
||||
nextTask,
|
||||
isLoading,
|
||||
isLoadingTasks,
|
||||
isLoadingMCP,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
refreshTasks,
|
||||
refreshMCPStatus,
|
||||
clearError
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskMasterContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TaskMasterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterContext;
|
||||
95
src/contexts/TasksSettingsContext.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
const TasksSettingsContext = createContext({
|
||||
tasksEnabled: true,
|
||||
setTasksEnabled: () => {},
|
||||
toggleTasksEnabled: () => {},
|
||||
isTaskMasterInstalled: null,
|
||||
isTaskMasterReady: null,
|
||||
installationStatus: null,
|
||||
isCheckingInstallation: true
|
||||
});
|
||||
|
||||
export const useTasksSettings = () => {
|
||||
const context = useContext(TasksSettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useTasksSettings must be used within a TasksSettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const TasksSettingsProvider = ({ children }) => {
|
||||
const [tasksEnabled, setTasksEnabled] = useState(() => {
|
||||
// Load from localStorage on initialization
|
||||
const saved = localStorage.getItem('tasks-enabled');
|
||||
return saved !== null ? JSON.parse(saved) : true; // Default to true
|
||||
});
|
||||
|
||||
const [isTaskMasterInstalled, setIsTaskMasterInstalled] = useState(null);
|
||||
const [isTaskMasterReady, setIsTaskMasterReady] = useState(null);
|
||||
const [installationStatus, setInstallationStatus] = useState(null);
|
||||
const [isCheckingInstallation, setIsCheckingInstallation] = useState(true);
|
||||
|
||||
// Save to localStorage whenever tasksEnabled changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('tasks-enabled', JSON.stringify(tasksEnabled));
|
||||
}, [tasksEnabled]);
|
||||
|
||||
// Check TaskMaster installation status asynchronously on component mount
|
||||
useEffect(() => {
|
||||
const checkInstallation = async () => {
|
||||
try {
|
||||
const response = await api.get('/taskmaster/installation-status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInstallationStatus(data);
|
||||
setIsTaskMasterInstalled(data.installation?.isInstalled || false);
|
||||
setIsTaskMasterReady(data.isReady || false);
|
||||
|
||||
// If TaskMaster is not installed and user hasn't explicitly enabled tasks,
|
||||
// disable tasks automatically
|
||||
const userEnabledTasks = localStorage.getItem('tasks-enabled');
|
||||
if (!data.installation?.isInstalled && !userEnabledTasks) {
|
||||
setTasksEnabled(false);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to check TaskMaster installation status');
|
||||
setIsTaskMasterInstalled(false);
|
||||
setIsTaskMasterReady(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking TaskMaster installation:', error);
|
||||
setIsTaskMasterInstalled(false);
|
||||
setIsTaskMasterReady(false);
|
||||
} finally {
|
||||
setIsCheckingInstallation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Run check asynchronously without blocking initial render
|
||||
setTimeout(checkInstallation, 0);
|
||||
}, []);
|
||||
|
||||
const toggleTasksEnabled = () => {
|
||||
setTasksEnabled(prev => !prev);
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
tasksEnabled,
|
||||
setTasksEnabled,
|
||||
toggleTasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
isTaskMasterReady,
|
||||
installationStatus,
|
||||
isCheckingInstallation
|
||||
};
|
||||
|
||||
return (
|
||||
<TasksSettingsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TasksSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TasksSettingsContext;
|
||||
29
src/contexts/WebSocketContext.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useWebSocket } from '../utils/websocket';
|
||||
|
||||
const WebSocketContext = createContext({
|
||||
ws: null,
|
||||
sendMessage: () => {},
|
||||
messages: [],
|
||||
isConnected: false
|
||||
});
|
||||
|
||||
export const useWebSocketContext = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const WebSocketProvider = ({ children }) => {
|
||||
const webSocketData = useWebSocket();
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketData}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketContext;
|
||||
41
src/hooks/useLocalStorage.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to persist state in localStorage.
|
||||
*
|
||||
* @param {string} key The key to use for localStorage.
|
||||
* @param {any} initialValue The initial value to use if nothing is in localStorage.
|
||||
* @returns {[any, Function]} A tuple containing the stored value and a setter function.
|
||||
*/
|
||||
function useLocalStorage(key, initialValue) {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
setStoredValue(valueToStore);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
@@ -5,28 +5,39 @@ import { version } from '../../package.json';
|
||||
export const useVersionCheck = (owner, repo) => {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState(null);
|
||||
const [releaseInfo, setReleaseInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkVersion = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Handle the case where there might not be any releases
|
||||
if (data.tag_name) {
|
||||
const latest = data.tag_name.replace(/^v/, '');
|
||||
setLatestVersion(latest);
|
||||
setUpdateAvailable(version !== latest);
|
||||
|
||||
// Store release information
|
||||
setReleaseInfo({
|
||||
title: data.name || data.tag_name,
|
||||
body: data.body || '',
|
||||
htmlUrl: data.html_url || `https://github.com/${owner}/${repo}/releases/latest`,
|
||||
publishedAt: data.published_at
|
||||
});
|
||||
} else {
|
||||
// No releases found, don't show update notification
|
||||
setUpdateAvailable(false);
|
||||
setLatestVersion(null);
|
||||
setReleaseInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Version check failed:', error);
|
||||
// On error, don't show update notification
|
||||
setUpdateAvailable(false);
|
||||
setLatestVersion(null);
|
||||
setReleaseInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,5 +46,5 @@ export const useVersionCheck = (owner, repo) => {
|
||||
return () => clearInterval(interval);
|
||||
}, [owner, repo]);
|
||||
|
||||
return { updateAvailable, latestVersion, currentVersion: version };
|
||||
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo };
|
||||
};
|
||||
124
src/index.css
@@ -43,6 +43,32 @@
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Safe area CSS variables */
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-right: env(safe-area-inset-right);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-left: env(safe-area-inset-left);
|
||||
|
||||
/* Mobile navigation dimensions - Single source of truth */
|
||||
--mobile-nav-height: 60px;
|
||||
--mobile-nav-padding: 12px;
|
||||
--mobile-nav-total: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), var(--mobile-nav-padding)));
|
||||
|
||||
/* Header safe area dimensions */
|
||||
--header-safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--header-base-padding: 8px;
|
||||
--header-total-padding: calc(var(--header-safe-area-top) + var(--header-base-padding));
|
||||
}
|
||||
|
||||
/* Fallback for older iOS versions */
|
||||
@supports (padding-top: constant(safe-area-inset-top)) {
|
||||
:root {
|
||||
--safe-area-inset-top: constant(safe-area-inset-top);
|
||||
--safe-area-inset-right: constant(safe-area-inset-right);
|
||||
--safe-area-inset-bottom: constant(safe-area-inset-bottom);
|
||||
--safe-area-inset-left: constant(safe-area-inset-left);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -63,7 +89,7 @@
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--input: 220 13% 46%;
|
||||
--ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
@@ -82,12 +108,54 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Root element with safe area padding for PWA */
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Apply safe area padding in standalone mode */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
@media (display-mode: standalone) {
|
||||
#root {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA mode detected by JavaScript - more reliable */
|
||||
html.pwa-mode,
|
||||
body.pwa-mode {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.pwa-mode #root {
|
||||
padding-top: var(--header-total-padding);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Adjust fixed inset positioning in PWA mode */
|
||||
body.pwa-mode .fixed.inset-0 {
|
||||
top: var(--header-total-padding);
|
||||
left: var(--safe-area-inset-left);
|
||||
right: var(--safe-area-inset-right);
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Global transition defaults */
|
||||
button,
|
||||
a,
|
||||
@@ -379,18 +447,18 @@
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
|
||||
.dark .chat-input-placeholder::placeholder {
|
||||
color: rgb(75 85 99) !important;
|
||||
opacity: 1 !important;
|
||||
-webkit-text-fill-color: rgb(75 85 99) !important;
|
||||
}
|
||||
|
||||
|
||||
.chat-input-placeholder::-webkit-input-placeholder {
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
|
||||
.dark .chat-input-placeholder::-webkit-input-placeholder {
|
||||
color: rgb(75 85 99) !important;
|
||||
opacity: 1 !important;
|
||||
@@ -577,6 +645,24 @@
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 12px);
|
||||
}
|
||||
|
||||
/* PWA specific header adjustments - uses CSS variables for consistency */
|
||||
.pwa-header-safe {
|
||||
padding-top: var(--header-base-padding);
|
||||
}
|
||||
|
||||
/* When PWA mode is detected by JavaScript */
|
||||
body.pwa-mode .pwa-header-safe {
|
||||
/* Reset padding since #root already handles safe area */
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
/* For mobile PWA, add bottom padding for better spacing */
|
||||
@media screen and (max-width: 768px) {
|
||||
body.pwa-mode .pwa-header-safe {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.chat-input-mobile {
|
||||
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
|
||||
@@ -603,6 +689,16 @@
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility for horizontal scroll */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide markdown backticks in prose content */
|
||||
@@ -639,9 +735,15 @@
|
||||
|
||||
/* Improved textarea styling */
|
||||
.chat-input-placeholder {
|
||||
display: block !important;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
||||
}
|
||||
|
||||
/* Ensure container fits textarea tightly */
|
||||
.chat-input-placeholder:not(:focus) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-input-placeholder::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -736,4 +838,16 @@
|
||||
background-color: rgb(31 41 55) !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
/* Tool details chevron animation */
|
||||
details[open] .details-chevron,
|
||||
details[open] summary svg[class*="group-open"] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Smooth chevron transition */
|
||||
.details-chevron,
|
||||
summary svg[class*="transition-transform"] {
|
||||
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
14
src/main.jsx
@@ -2,9 +2,21 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
// Clean up stale service workers on app load to prevent caching issues after builds
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.warn('Failed to unregister service workers:', err);
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
)
|
||||
|
||||
119
src/utils/api.js
@@ -1,15 +1,19 @@
|
||||
// Utility function for authenticated API calls
|
||||
export const authenticatedFetch = (url, options = {}) => {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
|
||||
const defaultHeaders = {};
|
||||
|
||||
// Only set Content-Type for non-FormData requests
|
||||
if (!(options.body instanceof FormData)) {
|
||||
defaultHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (!isPlatform && token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -37,14 +41,31 @@ export const api = {
|
||||
user: () => authenticatedFetch('/api/auth/user'),
|
||||
logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),
|
||||
},
|
||||
|
||||
|
||||
// Protected endpoints
|
||||
config: () => authenticatedFetch('/api/config'),
|
||||
// config endpoint removed - no longer needed (frontend uses window.location)
|
||||
projects: () => authenticatedFetch('/api/projects'),
|
||||
sessions: (projectName, limit = 5, offset = 0) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||
sessionMessages: (projectName, sessionId) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
|
||||
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== null) {
|
||||
params.append('limit', limit);
|
||||
params.append('offset', offset);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
|
||||
// Route to the correct endpoint based on provider
|
||||
let url;
|
||||
if (provider === 'codex') {
|
||||
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else if (provider === 'cursor') {
|
||||
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else {
|
||||
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
}
|
||||
return authenticatedFetch(url);
|
||||
},
|
||||
renameProject: (projectName, displayName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/rename`, {
|
||||
method: 'PUT',
|
||||
@@ -54,6 +75,10 @@ export const api = {
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteCodexSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
@@ -63,6 +88,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
createWorkspace: (workspaceData) =>
|
||||
authenticatedFetch('/api/projects/create-workspace', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(workspaceData),
|
||||
}),
|
||||
readFile: (projectName, filePath) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
||||
saveFile: (projectName, filePath, content) =>
|
||||
@@ -78,4 +108,71 @@ export const api = {
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
|
||||
// TaskMaster endpoints
|
||||
taskmaster: {
|
||||
// Initialize TaskMaster in a project
|
||||
init: (projectName) =>
|
||||
authenticatedFetch(`/api/taskmaster/init/${projectName}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Add a new task
|
||||
addTask: (projectName, { prompt, title, description, priority, dependencies }) =>
|
||||
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt, title, description, priority, dependencies }),
|
||||
}),
|
||||
|
||||
// Parse PRD to generate tasks
|
||||
parsePRD: (projectName, { fileName, numTasks, append }) =>
|
||||
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ fileName, numTasks, append }),
|
||||
}),
|
||||
|
||||
// Get available PRD templates
|
||||
getTemplates: () =>
|
||||
authenticatedFetch('/api/taskmaster/prd-templates'),
|
||||
|
||||
// Apply a PRD template
|
||||
applyTemplate: (projectName, { templateId, fileName, customizations }) =>
|
||||
authenticatedFetch(`/api/taskmaster/apply-template/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ templateId, fileName, customizations }),
|
||||
}),
|
||||
|
||||
// Update a task
|
||||
updateTask: (projectName, taskId, updates) =>
|
||||
authenticatedFetch(`/api/taskmaster/update-task/${projectName}/${taskId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
}),
|
||||
},
|
||||
|
||||
// Browse filesystem for project suggestions
|
||||
browseFilesystem: (dirPath = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (dirPath) params.append('path', dirPath);
|
||||
|
||||
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||
},
|
||||
|
||||
// User endpoints
|
||||
user: {
|
||||
gitConfig: () => authenticatedFetch('/api/user/git-config'),
|
||||
updateGitConfig: (gitName, gitEmail) =>
|
||||
authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gitName, gitEmail }),
|
||||
}),
|
||||
onboardingStatus: () => authenticatedFetch('/api/user/onboarding-status'),
|
||||
completeOnboarding: () =>
|
||||
authenticatedFetch('/api/user/complete-onboarding', {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
|
||||
// Generic GET method for any endpoint
|
||||
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
||||
};
|
||||
@@ -17,46 +17,31 @@ export function useWebSocket() {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, []); // Keep dependency array but add proper cleanup
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.warn('No authentication token found for 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')) {
|
||||
console.warn('Config returned localhost, using current host with API server port instead');
|
||||
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) {
|
||||
console.warn('Could not fetch server config, falling back to current host with API server port');
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
// Construct WebSocket URL
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
// Platform mode: Use same domain as the page (goes through proxy)
|
||||
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}`;
|
||||
wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
} else {
|
||||
// OSS mode: Connect to same host:port that served the page
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.warn('No authentication token found for WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
@@ -106,4 +91,4 @@ export function useWebSocket() {
|
||||
messages,
|
||||
isConnected
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||