29 Commits

Author SHA1 Message Date
simos
e2ba000e86 Release 1.10.4 2025-10-31 10:17:44 +01:00
simos
64e2909f0f feat(updates): add system update endpoint and UI
Add automatic update functionality to allow users to update the
application directly from the UI without manual git commands.

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

The update endpoint executes git checkout main, git pull, and npm
install, providing real-time output to the user. After successful
update, users are prompted to restart the server.
2025-10-31 09:15:50 +00:00
simos
6541760eb7 Release 1.10.3 2025-10-31 10:00:05 +01:00
simos
50454175c9 fix(agent): improve branch name and URL parsing
Enhance the robustness of GitHub URL parsing and branch name
generation with better regex patterns and edge case handling.

Changes:
- Update GitHub URL regex to use non-greedy matching and anchored
  .git suffix detection for more precise parsing
- Replace string-based .git removal with regex-based end anchor
- Add comprehensive validation for empty branch names with fallback
- Implement proper length calculation accounting for timestamp suffix
- Add final regex validation to ensure branch names meet safety
  requirements
- Improve edge case handling for hyphens after truncation
- Add deterministic fallback for invalid branch name patterns

These changes prevent potential parsing errors with malformed URLs
and ensure generated branch names always meet Git naming conventions.
2025-10-31 08:59:02 +00:00
simos
d6ceb222c3 Release 1.10.2 2025-10-31 09:48:04 +01:00
simos
9cfb7e659d modified: .gitignore
new file:   release.sh
2025-10-31 09:45:35 +01:00
simos
018b337871 modified: .gitignore
modified:   package.json
2025-10-31 09:42:56 +01:00
simos
0b8b1d0677 Release 1.10.1 2025-10-31 09:40:11 +01:00
simos
2e1e5b463a Release 1.10.0 2025-10-31 09:39:39 +01:00
simos
cafe18961e Merge pull request #220 from siteboon/feature/agent-auto-pr 2025-10-31 09:31:08 +01:00
simos
8f3a97b8b0 feat(agent): add automated branch and PR creation
Added createBranch and createPR options to the agent API endpoint, enabling automatic branch creation and pull request generation after successful agent task completion. Branch names are auto-generated from the agent message, and PR titles/descriptions are auto-generated from commit messages. This streamlines CI/CD workflows by eliminating manual Git operations after agent runs.
2025-10-31 09:29:52 +01:00
viper151
b612035b20 Merge pull request #218 from siteboon/feature/modernize-tool-design 2025-10-31 07:14:11 +01:00
viper151
c4e196692c Merge branch 'main' into feature/modernize-tool-design 2025-10-31 03:01:49 +01:00
simos
da6f35adc9 feat: UI updates to ChatInterface component and global styles. Changing how tools look like 2025-10-31 02:01:08 +00:00
viper151
bf1b3e7376 Merge pull request #215 from atelierai/master
Add markdown improvements: inline code normalization, table support, and copy button for code blocks.
2025-10-31 02:06:24 +01:00
viper151
df726c2d4f Merge branch 'main' into master 2025-10-31 02:00:01 +01:00
viper151
5af3706d69 Merge pull request #216 from siteboon/feature/edit-diff
Feature/edit diff
2025-10-31 01:58:21 +01:00
simos
53c1af33fa fix(App): wrap session handlers in useCallback to avoid warnings on depth
Wrap markSessionAsActive, markSessionAsInactive, markSessionAsProcessing,
markSessionAsNotProcessing, and replaceTemporarySession functions in
useCallback hooks to prevent unnecessary re-renders and stabilize
function references across component lifecycle. This optimization
ensures child components receiving these callbacks won't re-render
unnecessarily when AppContent re-renders.
2025-10-31 00:46:56 +00:00
simos
1bc2cf49ec fix(ui): stabilize token rate calculation in status component
Calculate token rate once per timing session instead of recalculating
on every interval tick. This prevents the simulated token count from
jumping erratically due to random value changes.

Also remove noisy console warning in websocket utility that was
cluttering development logs without providing actionable information.
2025-10-31 00:41:06 +00:00
simos
d2f02558a1 feat(editor): Change Code Editor to show diffs in source control panel and during messaging.
Add merge view and minimap extensions to CodeMirror for enhanced code
editing capabilities. Increase Express JSON and URL-encoded payload
limits from default (100kb) to 50mb to support larger file operations
and git diffs.
2025-10-31 00:37:20 +00:00
Sayo
a39a5fdd97 fix: fix the violation by moving useState to the top 2025-10-31 08:25:44 +08:00
Sayo
74607971a2 feat: codeblock add copy button when hover. 2025-10-31 07:45:59 +08:00
Sayo
eb835d21b2 feat: add remark-gfm to render table. improved display of inline code. 2025-10-31 07:42:55 +08:00
simos
eda89ef147 feat(api): add API for one-shot prompt generatio, key authentication system and git commit message generation
Implement comprehensive API key management functionality including
generation, validation, and CRUD operations.

Changes:
- Add API key database schema and operations (create, validate, delete,
  toggle)
- Generating a commit message will now work properly with claude sdk and cursor cli and return a suggested commit message
- Implement crypto-based key generation with 'ck_' prefix
- Add session ID tracking in claude-sdk.js and cursor-cli.js
- Update database layer with API key validation and last_used tracking
- Support multi-user API key management with user association

This enables secure programmatic access to the agent service
2025-10-30 20:59:25 +00:00
simos
9079326ac5 feat: Inform users about slash commands on chat interface 2025-10-30 14:37:09 +00:00
Andrew Garrett
7a087039c9 Make authentication database path configurable via DATABASE_PATH environment variable (#205)
* Make database path configurable via DATABASE_PATH environment variable

- Add DATABASE_PATH environment variable support in db.js
- Automatically create database directory if custom path is provided
- Update .env.example with DATABASE_PATH documentation for container deployments
- Maintain backward compatibility with default path (server/database/auth.db)

Co-authored-by: werdnum <271070+werdnum@users.noreply.github.com>

* Add error handling for creating DATABASE_PATH.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: werdnum <271070+werdnum@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: viper151 <simosmik@gmail.com>
2025-10-30 15:28:59 +01:00
viper151
de1f5d36f3 Update README.md 2025-10-30 15:20:51 +01:00
simos
d9eef6dcfe Release 1.9.1 2025-10-30 15:19:14 +01:00
simos
c9afc2e851 fix: resolve NPX redirect issue and improve startup documentation 2025-10-30 15:18:44 +01:00
28 changed files with 5214 additions and 546 deletions

View File

@@ -14,6 +14,15 @@ 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 should be set to a persistent volume path when running in containers
# Default: server/database/auth.db (relative to project root)
# Example for Docker: /data/auth.db
# DATABASE_PATH=/data/auth.db
# Claude Code context window size (maximum tokens per session)
# Note: VITE_ prefix makes it available to frontend
VITE_CONTEXT_WINDOW=160000

View File

@@ -67,7 +67,49 @@ No installation required, direct operation:
npx @siteboon/claude-code-ui
```
Your default browser will automatically open the Claude Code UI interface.
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
**To restart**: Simply run the same `npx` command again after stopping the server (Ctrl+C or Cmd+C).
### 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
```
**Benefits**:
- Faster startup (no download/cache check)
- Simple command to remember
- Same experience every time
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
### Run as Background Service (Optional)
To keep the server running in the background, use PM2:
```bash
# Install PM2 globally (one-time)
npm install -g pm2
# Start the server
pm2 start claude-code-ui --name "claude-ui"
# Manage the service
pm2 list # View status
pm2 restart claude-ui # Restart
pm2 stop claude-ui # Stop
pm2 logs claude-ui # View logs
pm2 startup # Auto-start on system boot
```
### Local Development Installation
@@ -236,13 +278,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
- 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

356
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.9.0",
"version": "1.10.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.9.0",
"version": "1.10.4",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -16,7 +16,10 @@
"@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",
"@octokit/rest": "^22.0.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
@@ -44,6 +47,7 @@
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"remark-gfm": "^4.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
@@ -536,6 +540,19 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/merge": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.11.1.tgz",
"integrity": "sha512-NleJ//mSmcal3jRdm9WwOVMUaJWvP2h69K96z3xTDJnde/nsMnLt9qfKUBkycWm5iO3/g4Zd69XTuTFErTZ72A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@@ -2170,7 +2187,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
"integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@@ -2180,7 +2196,6 @@
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz",
"integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/auth-token": "^6.0.0",
@@ -2199,7 +2214,6 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz",
"integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^14.0.0",
@@ -2213,14 +2227,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/endpoint/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2230,7 +2242,6 @@
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz",
"integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/request": "^10.0.2",
@@ -2245,14 +2256,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/graphql/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2262,14 +2271,12 @@
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz",
"integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz",
"integrity": "sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^14.1.0"
@@ -2285,14 +2292,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2302,7 +2307,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
"integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@@ -2315,7 +2319,6 @@
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.1.0.tgz",
"integrity": "sha512-nCsyiKoGRnhH5LkH8hJEZb9swpqOcsW+VXv1QoyUNQXJeVODG4+xM6UICEqyqe9XFr6LkL8BIiFCPev8zMDXPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^15.0.0"
@@ -2331,7 +2334,6 @@
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz",
"integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/endpoint": "^11.0.0",
@@ -2348,7 +2350,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz",
"integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^14.0.0"
@@ -2361,14 +2362,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/request-error/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2378,14 +2377,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/request/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2395,7 +2392,6 @@
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.0.tgz",
"integrity": "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/core": "^7.0.2",
@@ -2411,7 +2407,6 @@
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.0.tgz",
"integrity": "sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^26.0.0"
@@ -2450,6 +2445,23 @@
"node": ">=14.0.0"
}
},
"node_modules/@replit/codemirror-minimap": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@replit/codemirror-minimap/-/codemirror-minimap-0.5.2.tgz",
"integrity": "sha512-eNAtpr0hOG09/5zqAQ5PkgZEb3V/MHi30zentCxiR73r+utR2m9yVMCpBmfsWbb8mWxUWhMGPiHxM5hFtnscQA==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.5"
},
"peerDependencies": {
"@codemirror/language": "^6.9.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.21.3",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.1.6"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -3381,7 +3393,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
"integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/better-sqlite3": {
@@ -4703,6 +4714,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
@@ -4936,7 +4959,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
"integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6401,6 +6423,16 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6410,6 +6442,22 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -6434,6 +6482,107 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -6675,6 +6824,127 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -8736,6 +9006,24 @@
"node": ">=10"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -8769,6 +9057,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -10866,7 +11169,6 @@
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
"dev": true,
"license": "ISC"
},
"node_modules/unpipe": {

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.9.0",
"version": "1.10.4",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -27,7 +27,7 @@
"build": "vite build",
"preview": "vite preview",
"start": "npm run build && npm run server",
"release": "release-it"
"release": "./release.sh"
},
"keywords": [
"claude coode",
@@ -46,11 +46,16 @@
"@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",
"@octokit/rest": "^22.0.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-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0",
"chokidar": "^4.0.3",
@@ -72,12 +77,11 @@
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"remark-gfm": "^4.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0"
"ws": "^8.14.2"
},
"devDependencies": {
"@types/react": "^18.2.43",

857
public/api-docs.html Normal file
View File

@@ -0,0 +1,857 @@
<!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 or Cursor) 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> or <code>cursor</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>Model to use (for Cursor)</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>
// Dynamic URL replacement
const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl;
});
// Tab switching
function showTab(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>

4
release.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Load environment variables from .env
export $(grep -v '^#' .env | grep '^GITHUB_TOKEN=' | xargs)
exec npx release-it "$@"

View File

@@ -378,6 +378,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
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;

View File

@@ -94,6 +94,11 @@ async function spawnCursor(command, options = {}, ws) {
activeCursorProcesses.set(capturedSessionId, cursorProcess);
}
// Set session ID on writer (for API endpoint compatibility)
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;

View File

@@ -1,18 +1,34 @@
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');
// 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');
console.log(`Connected to SQLite database at: ${DB_PATH}`);
// Initialize database with schema
const initializeDatabase = async () => {
@@ -79,8 +95,169 @@ const userDb = {
}
};
// 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
};

View File

@@ -13,4 +13,37 @@ CREATE TABLE IF NOT EXISTS users (
-- 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);

View File

@@ -47,6 +47,8 @@ import cursorRoutes from './routes/cursor.js';
import taskmasterRoutes from './routes/taskmaster.js';
import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -170,7 +172,8 @@ const wss = new WebSocketServer({
app.locals.wss = wss;
app.use(cors());
app.use(express.json());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// Optional API key validation (if configured)
app.use('/api', validateApiKey);
@@ -196,6 +199,15 @@ app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
// Commands API Routes (protected)
app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
// Serve public files (like api-docs.html)
app.use(express.static(path.join(__dirname, '../public')));
// Static files served after API routes
// Add cache control: HTML files should not be cached, but assets can be cached
app.use(express.static(path.join(__dirname, '../dist'), {
@@ -225,6 +237,71 @@ app.get('/api/config', authenticateToken, (req, res) => {
});
});
// System update endpoint
app.post('/api/system/update', authenticateToken, async (req, res) => {
try {
// Get the project root directory (parent of server directory)
const projectRoot = path.join(__dirname, '..');
console.log('Starting system update from directory:', projectRoot);
// Run the update command
const updateCommand = 'git checkout main && git pull && npm install';
const child = spawn('sh', ['-c', updateCommand], {
cwd: projectRoot,
env: process.env
});
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
const text = data.toString();
output += text;
console.log('Update output:', text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
console.error('Update error:', text);
});
child.on('close', (code) => {
if (code === 0) {
res.json({
success: true,
output: output || 'Update completed successfully',
message: 'Update completed. Please restart the server to apply changes.'
});
} else {
res.status(500).json({
success: false,
error: 'Update command failed',
output: output,
errorOutput: errorOutput
});
}
});
child.on('error', (error) => {
console.error('Update process error:', error);
res.status(500).json({
success: false,
error: error.message
});
});
} catch (error) {
console.error('System update error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
@@ -397,7 +474,10 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
return res.status(404).json({ error: 'Project not found' });
}
const resolved = path.resolve(filePath);
// Handle both absolute and relative paths
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' });
@@ -493,21 +573,15 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
return res.status(404).json({ error: 'Project not found' });
}
const resolved = path.resolve(filePath);
// Handle both absolute and relative paths
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' });
}
// Create backup of original file
try {
const backupPath = resolved + '.backup.' + Date.now();
await fsPromises.copyFile(resolved, backupPath);
console.log('📋 Created backup:', backupPath);
} catch (backupError) {
console.warn('Could not create backup:', backupError.message);
}
// Write the new content
await fsPromises.writeFile(resolved, content, 'utf8');
@@ -1234,14 +1308,17 @@ app.get('*', (req, res) => {
// Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above
if (process.env.NODE_ENV === 'production') {
const indexPath = path.join(__dirname, '../dist/index.html');
// Check if dist/index.html exists (production build available)
if (fs.existsSync(indexPath)) {
// Set no-cache headers for HTML to prevent service worker issues
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(path.join(__dirname, '../dist/index.html'));
res.sendFile(indexPath);
} else {
// In development, redirect to Vite dev server
// In development, redirect to Vite dev server only if dist doesn't exist
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
}
});
@@ -1336,8 +1413,17 @@ async function startServer() {
await initializeDatabase();
console.log('✅ Database initialization skipped (testing)');
// Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath);
// Log Claude implementation mode
console.log('🚀 Using Claude Agents SDK for Claude integration');
console.log(`📦 Running in ${isProduction ? 'PRODUCTION' : 'DEVELOPMENT'} mode`);
if (!isProduction) {
console.log(`⚠️ Note: Requests will be proxied to Vite dev server at http://localhost:${process.env.VITE_PORT || 5173}`);
}
server.listen(PORT, '0.0.0.0', async () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);

View File

@@ -523,10 +523,12 @@ async function getProjects() {
async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
// agent-*.jsonl files contain session start data at this point. This needs to be revisited
// periodically to make sure only accurate data is there and no new functionality is added there
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
if (jsonlFiles.length === 0) {
return { sessions: [], hasMore: false, total: 0 };
@@ -803,10 +805,12 @@ async function parseJsonlSessions(filePath) {
// Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
// agent-*.jsonl files contain session start data at this point. This needs to be revisited
// periodically to make sure only accurate data is there and no new functionality is added there
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
if (jsonlFiles.length === 0) {
return { messages: [], total: 0, hasMore: false };

1184
server/routes/agent.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 {
@@ -122,32 +153,39 @@ 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 lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\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 = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
lines.map(line => `-${line}`).join('\n');
} else {
// Get diff for tracked files
// First check for unstaged changes (working tree vs index)
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
if (unstagedDiff) {
// Show unstaged changes if they exist
diff = unstagedDiff;
diff = stripDiffHeaders(unstagedDiff);
} else {
// If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
diff = stagedDiff || '';
diff = stripDiffHeaders(stagedDiff) || '';
}
}
res.json({ diff });
} catch (error) {
console.error('Git diff error:', error);
@@ -155,6 +193,61 @@ 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
currentContent = await fs.readFile(path.join(projectPath, file), '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 });
}
});
// Commit changes
router.post('/commit', async (req, res) => {
const { project, message, files } = req.body;
@@ -343,19 +436,24 @@ router.get('/commit-diff', async (req, res) => {
}
});
// Generate commit message based on staged changes
// Generate commit message based on staged changes using AI
router.post('/generate-commit-message', async (req, res) => {
const { project, files } = req.body;
const { project, files, provider = 'claude' } = req.body;
if (!project || !files || files.length === 0) {
return res.status(400).json({ error: 'Project name and files are required' });
}
// Validate provider
if (!['claude', 'cursor'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
}
try {
const projectPath = await getActualProjectPath(project);
// Get diff for selected files
let combinedDiff = '';
let diffContext = '';
for (const file of files) {
try {
const { stdout } = await execAsync(
@@ -363,17 +461,30 @@ 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 content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
} catch (error) {
console.error(`Error reading file ${file}:`, error);
}
}
}
// Generate commit message using AI
const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
res.json({ message });
} catch (error) {
console.error('Generate commit message error:', error);
@@ -381,46 +492,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 = `You are a git commit message generator. Based on the following file changes and diffs, generate a commit message in conventional commit format.
REQUIREMENTS:
- Use conventional commit format: type(scope): subject
- Include a body that explains what changed and why
- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Keep subject line under 50 characters
- Wrap body at 72 characters
- Be specific and descriptive
- Return ONLY the commit message, nothing else - no markdown, no explanations, no code blocks
FILES CHANGED:
${files.map(f => `- ${f}`).join('\n')}
DIFFS:
${diffContext.substring(0, 4000)}
Generate the commit message now:`;
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;

178
server/routes/settings.js Normal file
View 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;

View File

@@ -18,7 +18,7 @@
* 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 Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
@@ -42,7 +42,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([]);
@@ -184,11 +184,7 @@ function AppContent() {
if (!isSessionActive) {
// Session is not active - safe to reload messages
console.log('🔄 External CLI update detected for current session:', changedSessionId);
setExternalMessageUpdate(prev => prev + 1);
} else {
// Session is active - skip reload to avoid interrupting user
console.log('⏸️ External update paused - session is active:', changedSessionId);
}
}
}
@@ -482,14 +478,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);
@@ -497,19 +493,19 @@ function AppContent() {
return newSet;
});
}
};
}, []);
// Processing Session Functions: Track which sessions are currently thinking/processing
// markSessionAsProcessing: Called when Claude starts thinking/processing
const markSessionAsProcessing = (sessionId) => {
const markSessionAsProcessing = useCallback((sessionId) => {
if (sessionId) {
setProcessingSessions(prev => new Set([...prev, sessionId]));
}
};
}, []);
// markSessionAsNotProcessing: Called when Claude finishes thinking/processing
const markSessionAsNotProcessing = (sessionId) => {
const markSessionAsNotProcessing = useCallback((sessionId) => {
if (sessionId) {
setProcessingSessions(prev => {
const newSet = new Set(prev);
@@ -517,12 +513,12 @@ function AppContent() {
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();
@@ -536,12 +532,64 @@ 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 */}
@@ -550,9 +598,9 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal"
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6 space-y-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -563,7 +611,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
@@ -588,18 +638,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">
@@ -607,18 +696,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>
@@ -646,6 +751,7 @@ function AppContent() {
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
@@ -695,6 +801,7 @@ function AppContent() {
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}

View File

@@ -0,0 +1,398 @@
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';
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);
const token = localStorage.getItem('auth-token');
// Fetch API keys
const apiKeysRes = await fetch('/api/settings/api-keys', {
headers: { 'Authorization': `Bearer ${token}` }
});
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
const githubRes = await fetch('/api/settings/github-tokens', {
headers: { 'Authorization': `Bearer ${token}` }
});
const githubData = await githubRes.json();
setGithubTokens(githubData.tokens || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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 {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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 token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/github-tokens', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tokenName: newTokenName,
githubToken: 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 {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/github-tokens/${tokenId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub token:', error);
}
};
const toggleGithubToken = async (tokenId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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.token_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;

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,14 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
}
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);

View File

@@ -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,8 +7,9 @@ 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 { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
@@ -21,90 +22,150 @@ function CodeEditor({ file, onClose, projectPath }) {
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false);
const editorRef = useRef(null);
// 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;
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) 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, 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);
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
const createDiffDecorations = (content, diffInfo) => {
if (!diffInfo || !showDiff) return Decoration.none;
const builder = new RangeSetBuilder();
const lines = content.split('\n');
const oldLines = diffInfo.old_string.split('\n');
// Find the line where the old content starts
let startLineIndex = -1;
for (let i = 0; i <= lines.length - oldLines.length; i++) {
let matches = true;
for (let j = 0; j < oldLines.length; j++) {
if (lines[i + j] !== oldLines[j]) {
matches = false;
break;
}
}
if (matches) {
startLineIndex = i;
break;
}
}
update() {}
destroy() {}
})
];
}, [file.diffInfo, showDiff]);
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();
};
// Create diff navigation panel extension
const diffNavigationPanel = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
// 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 createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-diff-navigation-panel';
let currentIndex = 0;
const updatePanel = () => {
// Use getChunks API to get ALL chunks regardless of viewport
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
dom.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<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>
</div>
`;
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;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
};
updatePanel();
return {
top: true,
dom,
update: updatePanel
};
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
@@ -139,13 +200,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 +231,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}`);
@@ -258,11 +334,80 @@ function CodeEditor({ file, onClose, projectPath }) {
}
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 ${
<>
<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'};
}
/* Diff navigation panel styling */
.cm-diff-navigation-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 {
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
}
.cm-diff-nav-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`}
</style>
<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]')
@@ -287,7 +432,7 @@ function CodeEditor({ file, onClose, projectPath }) {
<p className="text-sm text-gray-500 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
{file.diffInfo && (
<button
@@ -298,19 +443,19 @@ function CodeEditor({ file, onClose, projectPath }) {
{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'
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"
@@ -318,7 +463,7 @@ function CodeEditor({ file, onClose, projectPath }) {
>
<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 +471,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,7 +495,7 @@ 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"
@@ -358,7 +503,7 @@ function CodeEditor({ file, onClose, projectPath }) {
>
{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,8 +522,21 @@ function CodeEditor({ file, onClose, projectPath }) {
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme,
...(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,
...diffNavigationPanel
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
@@ -409,14 +567,15 @@ function CodeEditor({ file, onClose, projectPath }) {
<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;

View File

@@ -0,0 +1,417 @@
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';
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);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const token = localStorage.getItem('auth-token');
// Fetch API keys
const apiKeysRes = await fetch('/api/settings/api-keys', {
headers: { 'Authorization': `Bearer ${token}` }
});
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub credentials only
const credentialsRes = await fetch('/api/settings/credentials?type=github_token', {
headers: { 'Authorization': `Bearer ${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 token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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 {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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 token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/credentials', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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 {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/credentials/${credentialId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub credential:', error);
}
};
const toggleGithubCredential = async (credentialId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/credentials/${credentialId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
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>
</div>
);
}
export default CredentialsSettings;

View File

@@ -4,7 +4,7 @@ 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);
@@ -35,6 +35,22 @@ function GitPanel({ selectedProject, isMobile }) {
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();
@@ -91,6 +107,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);
@@ -386,7 +408,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,
@@ -398,6 +420,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`);
@@ -435,10 +487,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);
@@ -593,14 +646,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

View File

@@ -451,7 +451,7 @@ function MainContent({
/>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
<GitPanel selectedProject={selectedProject} isMobile={isMobile} />
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div>
{shouldShowTasksTab && (
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>

View File

@@ -2,12 +2,13 @@ import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn } from 'lucide-react';
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import StandaloneShell from './StandaloneShell';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import CredentialsSettings from './CredentialsSettings';
function Settings({ isOpen, onClose, projects = [] }) {
const { isDarkMode, toggleDarkMode } = useTheme();
@@ -677,6 +678,17 @@ function Settings({ isOpen, onClose, projects = [] }) {
>
Tasks
</button>
<button
onClick={() => setActiveTab('api')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'api'
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Key className="w-4 h-4 inline mr-2" />
API & Tokens
</button>
</div>
</div>
@@ -1941,6 +1953,13 @@ function Settings({ isOpen, onClose, projects = [] }) {
)}
</div>
)}
{/* API & Tokens Tab */}
{activeTab === 'api' && (
<div className="space-y-6 md:space-y-8">
<CredentialsSettings />
</div>
)}
</div>
</div>

View File

@@ -39,12 +39,12 @@ const formatTimeAgo = (dateString, currentTime) => {
return date.toLocaleDateString();
};
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
onNewSession,
onSessionDelete,
onProjectDelete,
@@ -54,6 +54,7 @@ function Sidebar({
updateAvailable,
latestVersion,
currentVersion,
releaseInfo,
onShowVersionModal,
isPWA,
isMobile
@@ -1611,8 +1612,10 @@ function Sidebar({
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
</div>
</Button>
</div>
@@ -1630,8 +1633,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>

View File

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

View File

@@ -825,4 +825,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);
}
}

View File

@@ -41,7 +41,6 @@ export function useWebSocket() {
// 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;