Compare commits

..

3 Commits

Author SHA1 Message Date
viper151
bc52d9ea28 Merge branch 'main' into feat/notifications 2026-03-02 16:04:55 +01:00
simosmik
8339b8e624 Merge branch 'main' into feat/notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:27 +00:00
simosmik
061f0fd297 feat: introduce notification system and claude notifications 2026-02-27 14:44:44 +00:00
363 changed files with 11839 additions and 25286 deletions

View File

@@ -42,4 +42,4 @@ HOST=0.0.0.0
VITE_CONTEXT_WINDOW=160000
CONTEXT_WINDOW=160000
# VITE_IS_PLATFORM=false

View File

@@ -1,22 +0,0 @@
name: Discord Release Notification
on:
release:
types: [published]
jobs:
github-releases-to-discord:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Github Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.19.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
color: "2105893"
username: "Release Changelog"
avatar_url: "https://cdn.discordapp.com/avatars/487431320314576937/bd64361e4ba6313d561d54e78c9e7171.png"
content: "||@everyone||"
footer_title: "Changelog"
reduce_headings: true

10
.gitignore vendored
View File

@@ -108,7 +108,7 @@ temp/
.serena/
CLAUDE.md
.mcp.json
.gemini/
# Database files
*.db
@@ -130,11 +130,3 @@ dev-debug.log
# Task files
tasks.json
tasks/
# Translations
!src/i18n/locales/en/tasks.json
!src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json
# Git worktrees
.worktrees/

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "plugins/starter"]
path = plugins/starter
url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git

View File

@@ -1 +0,0 @@
npx commitlint --edit $1

View File

@@ -1 +0,0 @@
npx lint-staged

View File

@@ -1,6 +1,6 @@
{
"git": {
"commitMessage": "chore(release): v${version}",
"commitMessage": "Release ${version}",
"tagName": "v${version}",
"requireBranch": "main",
"requireCleanWorkingDir": true

View File

@@ -3,92 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11)
### New Features
* **i18n:** localize plugin settings for all languages ([#515](https://github.com/siteboon/claudecodeui/issues/515)) ([621853c](https://github.com/siteboon/claudecodeui/commit/621853cbfb4233b34cb8cc2e1ed10917ba424352))
### Bug Fixes
* codeql user value provided path validation ([aaa14b9](https://github.com/siteboon/claudecodeui/commit/aaa14b9fc0b9b51c4fb9d1dba40fada7cbbe0356))
* numerous bugs ([#528](https://github.com/siteboon/claudecodeui/issues/528)) ([a77f213](https://github.com/siteboon/claudecodeui/commit/a77f213dd5d0b2538dea091ab8da6e55d2002f2f))
* **security:** disable executable gray-matter frontmatter in commands ([b9c902b](https://github.com/siteboon/claudecodeui/commit/b9c902b016f411a942c8707dd07d32b60bad087c))
* session reconnect catch-up, always-on input, frozen session recovery ([#524](https://github.com/siteboon/claudecodeui/issues/524)) ([4d8fb6e](https://github.com/siteboon/claudecodeui/commit/4d8fb6e30aa03d7cdb92bd62b7709422f9d08e32))
### Refactoring
* new settings page design and new pill component ([8ddeeb0](https://github.com/siteboon/claudecodeui/commit/8ddeeb0ce8d0642560bd3fa149236011dc6e3707))
## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10)
### New Features
* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f))
* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115))
* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f))
### Bug Fixes
* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4))
### Maintenance
* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1))
## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)
### New Features
* add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a))
### Bug Fixes
* **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04))
* replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee))
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
### New Features
* add clickable overlay buttons for CLI prompts in Shell terminal ([#480](https://github.com/siteboon/claudecodeui/issues/480)) ([2444209](https://github.com/siteboon/claudecodeui/commit/2444209723701dda2b881cea2501b239e64e51c1)), closes [#427](https://github.com/siteboon/claudecodeui/issues/427)
* add terminal shortcuts panel for mobile ([#411](https://github.com/siteboon/claudecodeui/issues/411)) ([b0a3fdf](https://github.com/siteboon/claudecodeui/commit/b0a3fdf95ffdb961261194d10400267251e42f17))
* implement session rename with SQLite storage ([#413](https://github.com/siteboon/claudecodeui/issues/413)) ([198e3da](https://github.com/siteboon/claudecodeui/commit/198e3da89b353780f53a91888384da9118995e81)), closes [#72](https://github.com/siteboon/claudecodeui/issues/72) [#358](https://github.com/siteboon/claudecodeui/issues/358)
### Bug Fixes
* **chat:** finalize terminal lifecycle to prevent stuck processing/thinking UI ([#483](https://github.com/siteboon/claudecodeui/issues/483)) ([0590c5c](https://github.com/siteboon/claudecodeui/commit/0590c5c178f4791e2b039d525ecca4d220c3dcae))
* **codex-history:** prevent AGENTS.md/internal prompt leakage when reloading Codex sessions ([#488](https://github.com/siteboon/claudecodeui/issues/488)) ([64a96b2](https://github.com/siteboon/claudecodeui/commit/64a96b24f853acb802f700810b302f0f5cf00898))
* preserve pending permission requests across WebSocket reconnections ([#462](https://github.com/siteboon/claudecodeui/issues/462)) ([4ee88f0](https://github.com/siteboon/claudecodeui/commit/4ee88f0eb0c648b54b05f006c6796fb7b09b0fae))
* prevent React 18 batching from losing messages during session sync ([#461](https://github.com/siteboon/claudecodeui/issues/461)) ([688d734](https://github.com/siteboon/claudecodeui/commit/688d73477a50773e43c85addc96212aa6290aea5))
* release it script ([dcea8a3](https://github.com/siteboon/claudecodeui/commit/dcea8a329c7d68437e1e72c8c766cf33c74637e9))
### Styling
* improve UI for processing banner ([#477](https://github.com/siteboon/claudecodeui/issues/477)) ([2320e1d](https://github.com/siteboon/claudecodeui/commit/2320e1d74b59c65b5b7fc4fa8b05fd9208f4898c))
### Maintenance
* remove logging of received WebSocket messages in production ([#487](https://github.com/siteboon/claudecodeui/issues/487)) ([9193feb](https://github.com/siteboon/claudecodeui/commit/9193feb6dc83041f3c365204648a88468bdc001b))
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
### New Features
* add community button in the app ([84d4634](https://github.com/siteboon/claudecodeui/commit/84d4634735f9ee13ac1c20faa0e7e31f1b77cae8))
* Advanced file editor and file tree improvements ([#444](https://github.com/siteboon/claudecodeui/issues/444)) ([9768958](https://github.com/siteboon/claudecodeui/commit/97689588aa2e8240ba4373da5f42ab444c772e72))
* update document title based on selected project ([#448](https://github.com/siteboon/claudecodeui/issues/448)) ([9e22f42](https://github.com/siteboon/claudecodeui/commit/9e22f42a3d3a781f448ddac9d133292fe103bb8c))
### Bug Fixes
* **claude:** correct project encoded path ([#451](https://github.com/siteboon/claudecodeui/issues/451)) ([9c0e864](https://github.com/siteboon/claudecodeui/commit/9c0e864532dcc5ce7ee890d3b4db722872db2b54)), closes [#447](https://github.com/siteboon/claudecodeui/issues/447)
* **claude:** move model usage log to result message only ([#454](https://github.com/siteboon/claudecodeui/issues/454)) ([506d431](https://github.com/siteboon/claudecodeui/commit/506d43144b3ec3155c3e589e7e803862c4a8f83a))
* missing translation label ([855e22f](https://github.com/siteboon/claudecodeui/commit/855e22f9176a71daa51de716370af7f19d55bfb4))
### Maintenance
* add Gemini-CLI support to README ([#453](https://github.com/siteboon/claudecodeui/issues/453)) ([503c384](https://github.com/siteboon/claudecodeui/commit/503c3846850fb843781979b0c0e10a24b07e1a4b))
## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27)
### New Features

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
## スクリーンショット
@@ -193,8 +193,8 @@ npm run dev
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
2. **選択的に有効化** - 必要なツールのみを有効にする
3. **設定を適用** - 環境設定はローカルに保存されます
3. **選択的に有効化** - 必要なツールのみを有効にする
4. **設定を適用** - 環境設定はローカルに保存されます
<div align="center">

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## 스크린샷
@@ -193,8 +193,8 @@ npm run dev
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
2. **선택적으로 활성화** - 필요한 도구만 활성화
3. **설정 적용** - 환경설정은 로컬에 저장됩니다
3. **선택적으로 활성화** - 필요한 도구만 활성화
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
<div align="center">

300
README.md
View File

@@ -1,23 +1,13 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## Screenshots
@@ -43,7 +33,7 @@
<h3>CLI Selection</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em>
<em>Select between Claude Code, Cursor CLI and Codex</em>
</td>
</tr>
</table>
@@ -60,66 +50,141 @@
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
## Quick Start
### CloudCLI Cloud (Recommended)
### Prerequisites
The fastest way to get started — no local setup required. Get a fully managed, containerized development environment accessible from the web, mobile app, API, or your favorite IDE.
- [Node.js](https://nodejs.org/) v22 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
- [Gemini-CLI](https://geminicli.com/) installed and configured
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### One-click Operation (Recommended)
No installation required, direct operation:
### Self-Hosted (Open source)
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
```
```bash
npx @siteboon/claude-code-ui
```
Or install **globally** for regular use:
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
```
**To restart**: Simply run the same `npx` command again after stopping the server
### Global Installation (For Regular Use)
For frequent use, install globally once:
```bash
npm install -g @siteboon/claude-code-ui
cloudcli
```
Open `http://localhost:3001` — all your existing sessions are discovered automatically.
Then start with a simple command:
Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more
```bash
claude-code-ui
```
---
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
## Which option is right for you?
**To update**:
```bash
cloudcli update
```
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations.
### CLI Usage
| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |
|---|---|---|
| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere |
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n |
| **Setup** | `npx @siteboon/claude-code-ui` | No setup required |
| **Machine needs to stay on** | Yes | No |
| **Mobile access** | Any browser on your network | Any device, native app coming |
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment |
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI |
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI |
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment |
| **REST API** | Yes | Yes |
| **n8n node** | No | Yes |
| **Team sharing** | No | Yes |
| **Platform cost** | Free, open source | Starts at $7/month |
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
| Command / Option | Short | Description |
|------------------|-------|-------------|
| `cloudcli` or `claude-code-ui` | | Start the server (default) |
| `cloudcli start` | | Start the server explicitly |
| `cloudcli status` | | Show configuration and data locations |
| `cloudcli update` | | Update to the latest version |
| `cloudcli help` | | Show help information |
| `cloudcli version` | | Show version information |
| `--port <port>` | `-p` | Set server port (default: 3001) |
| `--database-path <path>` | | Set custom database location |
---
**Examples:**
```bash
cloudcli # Start with defaults
cloudcli -p 8080 # Start on custom port
cloudcli status # Show current configuration
```
### Run as Background Service (Recommended for Production)
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
#### Install PM2
```bash
npm install -g pm2
```
#### Start as Background Service
```bash
# Start the server in background
pm2 start claude-code-ui --name "claude-code-ui"
# Or using the shorter alias
pm2 start cloudcli --name "claude-code-ui"
# Start on a custom port
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
```
#### Auto-Start on System Boot
To make Claude Code UI start automatically when your system boots:
```bash
# Generate startup script for your platform
pm2 startup
# Save current process list
pm2 save
```
### Local Development Installation
1. **Clone the repository:**
```bash
git clone https://github.com/siteboon/claudecodeui.git
cd claudecodeui
```
2. **Install dependencies:**
```bash
npm install
```
3. **Configure environment:**
```bash
cp .env.example .env
# Edit .env with your preferred settings
```
4. **Start the application:**
```bash
# Development mode (with hot reload)
npm run dev
```
The application will start at the port you specified in your .env
5. **Open your browser:**
- Development: `http://localhost:3001`
## Security & Tools Configuration
@@ -130,8 +195,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
To use Claude Code's full functionality, you'll need to manually enable tools:
1. **Open Tools Settings** - Click the gear icon in the sidebar
2. **Enable Selectively** - Turn on only the tools you need
3. **Apply Settings** - Your preferences are saved locally
3. **Enable Selectively** - Turn on only the tools you need
4. **Apply Settings** - Your preferences are saved locally
<div align="center">
@@ -142,73 +207,114 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
---
## TaskMaster AI Integration *(Optional)*
## Plugins
Claude Code UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
CloudCLI has a plugin system that lets you add custom tabs with their own frontend UI and optional Node.js backend. Install plugins from git repos directly in **Settings > Plugins**, or build your own.
It provides
- AI-powered task generation from PRDs (Product Requirements Documents)
- Smart task breakdown and dependency management
- Visual task boards and progress tracking
### Available Plugins
**Setup & Documentation**: Visit the [TaskMaster AI GitHub repository](https://github.com/eyaltoledano/claude-task-master) for installation instructions, configuration guides, and usage examples.
After installing it you should be able to enable it from the Settings
| Plugin | Description |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
### Build Your Own
## Usage Guide
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
### Core Features
**[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — full guide to the plugin API, manifest format, security model, and more.
#### Project Management
It automatically discovers Claude Code, Cursor or Codex sessions when available and groups them together into projects
session counts
- **Project Actions** - Rename, delete, and organize projects
- **Smart Navigation** - Quick access to recent projects and sessions
- **MCP support** - Add your own MCP servers through the UI
---
## FAQ
#### Chat Interface
- **Use responsive chat or Claude Code/Cursor CLI/Codex CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI.
- **Real-time Communication** - Stream responses from your selected CLI (Claude Code/Cursor/Codex) with WebSocket connection
- **Session Management** - Resume previous conversations or start fresh sessions
- **Message History** - Complete conversation history with timestamps and metadata
- **Multi-format Support** - Text, code blocks, and file references
<details>
<summary>How is this different from Claude Code Remote Control?</summary>
#### File Explorer & Editor
- **Interactive File Tree** - Browse project structure with expand/collapse navigation
- **Live File Editing** - Read, modify, and save files directly in the interface
- **Syntax Highlighting** - Support for multiple programming languages
- **File Operations** - Create, rename, delete files and directories
Claude Code Remote Control lets you send messages to a session already running in your local terminal. Your machine has to stay on, your terminal has to stay open, and sessions time out after roughly 10 minutes without a network connection.
#### Git Explorer
CloudCLI UI and CloudCLI Cloud extend Claude Code rather than sit alongside it — your MCP servers, permissions, settings, and sessions are the exact same ones Claude Code uses natively. Nothing is duplicated or managed separately.
Here's what that means in practice:
#### TaskMaster AI Integration *(Optional)*
- **Visual Task Board** - Kanban-style interface for managing development tasks
- **PRD Parser** - Create Product Requirements Documents and parse them into structured tasks
- **Progress Tracking** - Real-time status updates and completion tracking
- **All your sessions, not just one** — CloudCLI UI auto-discovers every session from your `~/.claude` folder. Remote Control only exposes the single active session to make it available in the Claude mobile app.
- **Your settings are your settings** — MCP servers, tool permissions, and project config you change in CloudCLI UI are written directly to your Claude Code config and take effect immediately, and vice versa.
- **Works with more agents** — Claude Code, Cursor CLI, Codex, and Gemini CLI, not just Claude Code.
- **Full UI, not just a chat window** — file explorer, Git integration, MCP management, and a shell terminal are all built in.
- **CloudCLI Cloud runs in the cloud** — close your laptop, the agent keeps running. No terminal to babysit, no machine to keep awake.
#### Session Management
- **Session Persistence** - All conversations automatically saved
- **Session Organization** - Group sessions by project and timestamp
- **Session Actions** - Rename, delete, and export conversation history
- **Cross-device Sync** - Access sessions from any device
</details>
### Mobile App
- **Responsive Design** - Optimized for all screen sizes
- **Touch-friendly Interface** - Swipe gestures and touch navigation
- **Mobile Navigation** - Bottom tab bar for easy thumb navigation
- **Adaptive Layout** - Collapsible sidebar and smart content prioritization
- **Add shortcut to Home Screen** - Add a shortcut to your home screen and the app will behave like a PWA
<details>
<summary>Do I need to pay for an AI subscription separately?</summary>
## Architecture
Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that.
### System Overview
</details>
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Agent │
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
<details>
<summary>Can I use CloudCLI UI on my phone?</summary>
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
- **File System API** - Exposing file browser for projects
Yes. For self-hosted, run the server on your machine and open `[yourip]:port` in any browser on your network. For CloudCLI Cloud, open it from any device — no VPN, no port forwarding, no setup. A native app is also in the works.
### Frontend (React + Vite)
- **React 18** - Modern component architecture with hooks
- **CodeMirror** - Advanced code editor with syntax highlighting
</details>
<details>
<summary>Will changes I make in the UI affect my local Claude Code setup?</summary>
Yes, for self-hosted. CloudCLI UI reads from and writes to the same `~/.claude` config that Claude Code uses natively. MCP servers you add via the UI show up in Claude Code immediately and vice versa.
</details>
---
### Contributing
## Community & Support
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on commit conventions, development workflow, and release process.
## Troubleshooting
### Common Issues & Solutions
#### "No Claude projects found"
**Problem**: The UI shows no projects or empty project list
**Solutions**:
- Ensure [Claude Code](https://docs.anthropic.com/en/docs/claude-code) is properly installed
- Run `claude` command in at least one project directory to initialize
- Verify `~/.claude/projects/` directory exists and has proper permissions
#### File Explorer Issues
**Problem**: Files not loading, permission errors, empty directories
**Solutions**:
- Check project directory permissions (`ls -la` in terminal)
- Verify the project path exists and is accessible
- Review server console logs for detailed error messages
- Ensure you're not trying to access system directories outside project scope
- **[Documentation](https://cloudcli.ai/docs)** — installation, configuration, features, and troubleshooting
- **[Discord](https://discord.gg/buxwujPNRE)** — get help and connect with other users
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — bug reports and feature requests
- **[Contributing Guide](CONTRIBUTING.md)** — how to contribute to the project
## License
@@ -229,6 +335,12 @@ This project is open source and free to use, modify, and distribute under the GP
- **[CodeMirror](https://codemirror.net/)** - Advanced code editor
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning
## Support & Community
### Stay Updated
- **Star** this repository to show support
- **Watch** for updates and new releases
- **Follow** the project for announcements
### Sponsors
- [Siteboon - AI powered website builder](https://siteboon.ai)

View File

@@ -1,218 +0,0 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
</div>
Десктопный и мобильный UI для [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex) и [Gemini-CLI](https://geminicli.com/). Его можно использовать локально или удаленно, чтобы просматривать активные проекты и сессии и вносить изменения откуда угодно, с мобильного или десктопа. Это дает полноценный интерфейс, который работает везде.
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Сообщить об ошибке</a> · <a href="CONTRIBUTING.md">Участие в разработке</a>
</p>
<p align="center">
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## Скриншоты
<div align="center">
<table>
<tr>
<td align="center">
<h3>Версия для десктопа</h3>
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
<br>
<em>Основной интерфейс с обзором проекта и чатом</em>
</td>
<td align="center">
<h3>Мобильный режим</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
<br>
<em>Адаптивный мобильный интерфейс с сенсорной навигацией</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>Выбор CLI</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Выбор между Claude Code, Cursor CLI, Codex и Gemini CLI</em>
</td>
</tr>
</table>
</div>
## Возможности
- **Адаптивный дизайн** - одинаково хорошо работает на десктопе, планшете и телефоне, поэтому пользоваться агентами можно и с мобильных устройств
- **Интерактивный чат-интерфейс** - встроенный чат для удобного взаимодействия с агентами
- **Встроенный shell-терминал** - прямой доступ к CLI агентов через встроенную оболочку
- **Файловый менеджер** - интерактивное дерево файлов с подсветкой синтаксиса и live-редактированием
- **Git Explorer** - просмотр, stage и commit изменений, а также переключение веток
- **Управление сессиями** - возобновление диалогов, работа с несколькими сессиями и история
- **Интеграция с TaskMaster AI** *(опционально)* - расширенное управление проектами с AI-планированием задач, разбором PRD и автоматизацией workflows
- **Совместимость с моделями** - работает с Claude Sonnet 4.5, Opus 4.5, GPT-5.2 и Gemini.
## Быстрый старт
### CloudCLI Cloud (рекомендуется)
Самый быстрый способ начать работу: локальная настройка не требуется. Вы получаете полностью управляемую контейнеризированную среду разработки с доступом из браузера, мобильного приложения, API или любимой IDE.
**[Начать с CloudCLI Cloud](https://cloudcli.ai)**
### Self-Hosted (open source)
Попробовать CloudCLI UI можно сразу через **npx** (нужен **Node.js** v22+):
```bash
npx @siteboon/claude-code-ui
```
Или установить **глобально** для постоянного использования:
```bash
npm install -g @siteboon/claude-code-ui
cloudcli
```
Откройте `http://localhost:3001` — все существующие сессии будут обнаружены автоматически.
Больше вариантов настройки, PM2, удаленный сервер и остальное описаны в **[документации →](https://cloudcli.ai/docs)**
---
## Какой вариант подойдет вам?
CloudCLI UI - это open source UI-слой, на котором построен CloudCLI Cloud. Вы можете развернуть его у себя на машине или использовать CloudCLI Cloud, который добавляет полностью управляемую облачную среду, командные функции и более глубокие интеграции.
| | CloudCLI UI (self-hosted) | CloudCLI Cloud |
|---|---|---|
| **Лучше всего подходит для** | Разработчиков, которым нужен полноценный UI для локальных агентских сессий на своей машине | Команд и разработчиков, которым нужны агенты в облаке с доступом откуда угодно |
| **Способ доступа** | Браузер через `[yourip]:port` | Браузер, любая IDE, REST API, n8n |
| **Настройка** | `npx @siteboon/claude-code-ui` | Настройка не требуется |
| **Машина должна оставаться включенной** | Да | Нет |
| **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке |
| **Доступные сессии** | Все сессии автоматически обнаруживаются в `~/.claude` | Все сессии внутри вашей облачной среды |
| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **Файловый менеджер и Git** | Да, встроены в UI | Да, встроены в UI |
| **Конфигурация MCP** | Управляется через UI, синхронизируется с локальным `~/.claude` | Управляется через UI |
| **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к облачной среде |
| **REST API** | Да | Да |
| **Узел n8n** | Нет | Да |
| **Совместная работа в команде** | Нет | Да |
| **Стоимость платформы** | Бесплатно, open source | От $7/месяц |
> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
---
## Безопасность и настройка инструментов
**🔒 Важно**: все инструменты Claude Code **по умолчанию отключены**. Это предотвращает автоматический запуск потенциально опасных операций.
### Включение инструментов
Чтобы использовать всю функциональность Claude Code, инструменты нужно включить вручную:
1. **Откройте настройки инструментов** - нажмите на иконку шестеренки в боковой панели
2. **Включайте выборочно** - активируйте только те инструменты, которые действительно нужны
3. **Примените настройки** - предпочтения сохраняются локально
<div align="center">
![Tools Settings Modal](public/screenshots/tools-modal.png)
*Окно настройки инструментов - включайте только то, что вам нужно*
</div>
**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно поменять позже.
---
## FAQ
<details>
<summary>Чем это отличается от Claude Code Remote Control?</summary>
Claude Code Remote Control позволяет отправлять сообщения в сессию, уже запущенную в локальном терминале. При этом ваша машина должна оставаться включенной, терминал должен быть открыт, а сессии завершаются примерно через 10 минут без сетевого соединения.
CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работают рядом с ним — ваши MCP-серверы, разрешения, настройки и сессии остаются теми же самыми, что и в нативном Claude Code. Ничего не дублируется и не управляется отдельно.
Вот что это означает на практике:
- **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать ее доступной в мобильном приложении Claude.
- **Ваши настройки остаются вашими** — MCP-серверы, права инструментов и конфигурация проекта, измененные в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот.
- **Поддержка большего числа агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code.
- **Полноценный UI, а не просто окно чата** — встроены файловый менеджер, Git-интеграция, управление MCP и shell-терминал.
- **CloudCLI Cloud работает в облаке** — можно закрыть ноутбук, а агент продолжит работу. Не нужно держать терминал открытым и машину в активном состоянии.
</details>
<details>
<summary>Нужно ли отдельно платить за AI-подписку?</summary>
Да. CloudCLI предоставляет среду, а не сам AI. Вы используете собственную подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud стоит от $7/месяц за хостируемую среду сверх этого.
</details>
<details>
<summary>Можно ли пользоваться CloudCLI UI с телефона?</summary>
Да. Для self-hosted запустите сервер на своей машине и откройте `[yourip]:port` в любом браузере внутри вашей сети. Для CloudCLI Cloud откройте сервис с любого устройства — без VPN, проброса портов и дополнительной настройки. Нативное приложение тоже разрабатывается.
</details>
<details>
<summary>Повлияют ли изменения, сделанные в UI, на мой локальный Claude Code?</summary>
Да, в self-hosted режиме. CloudCLI UI читает и записывает тот же конфиг `~/.claude`, который нативно использует Claude Code. MCP-серверы, добавленные через UI, сразу появляются в Claude Code, и наоборот.
</details>
---
## Сообщество и поддержка
- **[Документация](https://cloudcli.ai/docs)** — установка, настройка, возможности и устранение неполадок
- **[Discord](https://discord.gg/buxwujPNRE)** — помощь и общение с другими пользователями
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — баг-репорты и запросы новых функций
- **[Руководство для контрибьюторов](CONTRIBUTING.md)** — как участвовать в развитии проекта
## Лицензия
GNU General Public License v3.0 - подробности в файле [LICENSE](LICENSE).
Этот проект открыт и может свободно использоваться, изменяться и распространяться по лицензии GPL v3.
## Благодарности
### Используется
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - библиотека пользовательских интерфейсов
- **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер
- **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework
- **[CodeMirror](https://codemirror.net/)** - продвинутый редактор кода
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(опционально)* - AI-управление проектами и планирование задач
### Спонсоры
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>Сделано с любовью к сообществу Claude Code, Cursor и Codex.</strong>
</div>

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a></i></div>
## 截图
@@ -194,8 +194,8 @@ npm run dev
要使用 Claude Code 的完整功能,您需要手动启用工具:
1. **打开工具设置** - 点击侧边栏中的齿轮图标
2. **选择性启用** - 仅打开您需要的工具
3. **应用设置** - 您的偏好设置将保存在本地
3. **选择性启用** - 仅打开您需要的工具
4. **应用设置** - 您的偏好设置将保存在本地
<div align="center">
@@ -344,4 +344,4 @@ GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
<div align="center">
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
</div>
</div>

View File

@@ -1,3 +0,0 @@
export default {
extends: ["@commitlint/config-conventional"],
};

View File

@@ -1,102 +0,0 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import importX from "eslint-plugin-import-x";
import tailwindcss from "eslint-plugin-tailwindcss";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**", "public/**"],
},
{
files: ["src/**/*.{ts,tsx,js,jsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
plugins: {
react,
"react-hooks": reactHooks, // for following React rules such as dependencies in hooks, keys in lists, etc.
"react-refresh": reactRefresh, // for Vite HMR compatibility
"import-x": importX, // for import order/sorting. It also detercts circular dependencies and duplicate imports.
tailwindcss, // for detecting invalid Tailwind classnames and enforcing classname order
"unused-imports": unusedImports, // for detecting unused imports
},
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
settings: {
react: { version: "detect" },
},
rules: {
// --- Unused imports/vars ---
"unused-imports/no-unused-imports": "warn",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
// --- React ---
"react/jsx-key": "warn",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "warn",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-unknown-property": "warn",
"react/react-in-jsx-scope": "off",
// --- React Hooks ---
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
// --- React Refresh (Vite HMR) ---
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
// --- Import ordering & hygiene ---
"import-x/no-duplicates": "warn",
"import-x/order": [
"warn",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
],
"newlines-between": "never",
},
],
// --- Tailwind CSS ---
"tailwindcss/classnames-order": "warn",
"tailwindcss/no-contradicting-classname": "warn",
"tailwindcss/no-unnecessary-arbitrary-value": "warn",
// --- Disabled base rules ---
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-require-imports": "off",
"no-case-declarations": "off",
"no-control-regex": "off",
"no-useless-escape": "off",
},
}
);

View File

@@ -8,7 +8,7 @@
<title>CloudCLI UI</title>
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="manifest" href="/manifest.json" />
<!-- iOS Safari PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" />
@@ -45,4 +45,4 @@
}
</script>
</body>
</html>
</html>

4615
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.25.2",
"version": "1.21.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -30,13 +30,10 @@
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"start": "npm run build && npm run server",
"release": "./release.sh",
"prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky"
"postinstall": "node scripts/fix-node-pty.js"
},
"keywords": [
"claude code",
@@ -81,7 +78,6 @@
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
@@ -103,12 +99,10 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
@@ -117,26 +111,12 @@
"auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"eslint": "^9.39.3",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"node-gyp": "^10.0.0",
"postcss": "^8.4.32",
"release-it": "^19.0.5",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.0.4"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint"
}
}

Submodule plugins/starter deleted from bfa6332810

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -22,11 +22,9 @@ self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached response if found
if (response) {
return response;
}
// Otherwise fetch from network
return fetch(event.request);
}
)
@@ -46,4 +44,53 @@ self.addEventListener('activate', event => {
);
})
);
});
self.clients.claim();
});
// Push notification event
self.addEventListener('push', event => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: 'Claude Code UI', body: event.data.text() };
}
const options = {
body: payload.body || '',
icon: '/logo.png',
badge: '/logo.png',
data: payload.data || {},
tag: payload.data?.code || 'default',
renotify: true
};
event.waitUntil(
self.registration.showNotification(payload.title || 'Claude Code UI', options)
);
});
// Notification click event
self.addEventListener('notificationclick', event => {
event.notification.close();
const sessionId = event.notification.data?.sessionId;
const urlPath = sessionId ? `/session/${sessionId}` : '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
for (const client of clientList) {
if (client.url.includes(self.location.origin)) {
client.focus();
if (sessionId) {
client.navigate(self.location.origin + urlPath);
}
return;
}
}
return self.clients.openWindow(urlPath);
})
);
});

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
@@ -34,7 +35,7 @@ function createRequestId() {
}
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
return new Promise(resolve => {
let settled = false;
@@ -78,14 +79,9 @@ function waitForToolApproval(requestId, options = {}) {
signal.addEventListener('abort', abortHandler, { once: true });
}
const resolver = (decision) => {
pendingToolApprovals.set(requestId, (decision) => {
finalize(decision);
};
// Attach metadata for getPendingApprovalsForSession lookup
if (metadata) {
Object.assign(resolver, metadata);
}
pendingToolApprovals.set(requestId, resolver);
});
});
}
@@ -214,14 +210,13 @@ function mapCliOptionsToSDK(options = {}) {
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
* @param {string} tempDir - Temp directory for cleanup
*/
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
activeSessions.set(sessionId, {
instance: queryInstance,
startTime: Date.now(),
status: 'active',
tempImagePaths,
tempDir,
writer
tempDir
});
}
@@ -467,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
let tempImagePaths = [];
let tempDir = null;
const emitNotification = (event) => {
notifyUserIfEnabled({
userId: ws?.userId || null,
writer: ws,
event
});
};
try {
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options);
@@ -483,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
sdkOptions.hooks = {
Notification: [{
matcher: '',
hooks: [async (input) => {
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'agent.notification',
meta: { message },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
}));
return {};
}]
}],
Stop: [{
matcher: '',
hooks: [async (input) => {
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason },
severity: 'info',
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
}));
return {};
}]
}]
};
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -514,16 +553,20 @@ async function queryClaudeSDK(command, options = {}, ws) {
input,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'permission.required',
meta: { toolName },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
}));
const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined,
signal: context?.signal,
metadata: {
_sessionId: capturedSessionId || sessionId || null,
_toolName: toolName,
_input: input,
_receivedAt: new Date(),
},
onCancel: (reason) => {
ws.send({
type: 'claude-permission-cancelled',
@@ -560,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
const queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
let queryInstance;
try {
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
} catch (hookError) {
// Older/newer SDK versions may not accept hook shapes yet.
// Keep notification behavior operational via runtime events even if hook registration fails.
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
delete sdkOptions.hooks;
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
}
// Restore immediately — Query constructor already captured the value
if (prevStreamTimeout !== undefined) {
@@ -574,7 +629,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Track the query instance for abort capability
if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
}
// Process streaming messages
@@ -584,7 +639,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
if (message.session_id && !capturedSessionId) {
capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
// Set session ID on writer
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
@@ -666,6 +721,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'error',
code: 'run.failed',
meta: { error: error.message },
severity: 'error',
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
}));
throw error;
}
@@ -724,50 +788,11 @@ function getActiveClaudeSDKSessions() {
return getAllSessions();
}
/**
* Get pending tool approvals for a specific session.
* @param {string} sessionId - The session ID
* @returns {Array} Array of pending permission request objects
*/
function getPendingApprovalsForSession(sessionId) {
const pending = [];
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
if (resolver._sessionId === sessionId) {
pending.push({
requestId,
toolName: resolver._toolName || 'UnknownTool',
input: resolver._input,
context: resolver._context,
sessionId,
receivedAt: resolver._receivedAt || new Date(),
});
}
}
return pending;
}
/**
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
* Called when client reconnects (e.g. page refresh) while SDK is still running.
* @param {string} sessionId - The session ID
* @param {Object} newRawWs - The new raw WebSocket connection
* @returns {boolean} True if writer was successfully reconnected
*/
function reconnectSessionWriter(sessionId, newRawWs) {
const session = getSession(sessionId);
if (!session?.writer?.updateWebSocket) return false;
session.writer.updateWebSocket(newRawWs);
console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
return true;
}
// Export public API
export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions,
resolveToolApproval,
getPendingApprovalsForSession,
reconnectSessionWriter
resolveToolApproval
};

View File

@@ -1,124 +1,84 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID
const WORKSPACE_TRUST_PATTERNS = [
/workspace trust required/i,
/do you trust the contents of this directory/i,
/working with untrusted contents/i,
/pass --trust,\s*--yolo,\s*or -f/i
];
function isWorkspaceTrustPrompt(text = '') {
if (!text || typeof text !== 'string') {
return false;
}
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
}
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false;
let settled = false;
let messageBuffer = ''; // Buffer for accumulating assistant messages
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedShellCommands: [],
skipPermissions: false
};
// Build Cursor CLI command
const baseArgs = [];
const args = [];
// Build flags allowing both resume and prompt together (reply in existing session)
// Treat presence of sessionId as intention to resume, regardless of resume flag
if (sessionId) {
baseArgs.push('--resume=' + sessionId);
args.push('--resume=' + sessionId);
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
baseArgs.push('-p', command);
args.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
baseArgs.push('--model', model);
args.push('--model', model);
}
// Request streaming JSON when we are providing a prompt
baseArgs.push('--output-format', 'stream-json');
args.push('--output-format', 'stream-json');
}
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
baseArgs.push('-f');
console.log('Using -f flag (skip permissions)');
args.push('-f');
console.log('⚠️ Using -f flag (skip permissions)');
}
// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
const settleOnce = (callback) => {
if (settled) {
return;
}
settled = true;
callback();
};
const runCursorProcess = (args, runReason = 'initial') => {
const isTrustRetry = runReason === 'trust-retry';
let runSawWorkspaceTrustPrompt = false;
let stdoutLineBuffer = '';
if (isTrustRetry) {
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
}
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
activeCursorProcesses.set(processKey, cursorProcess);
const shouldSuppressForTrustRetry = (text) => {
if (hasRetriedWithTrust || args.includes('--trust')) {
return false;
}
if (!isWorkspaceTrustPrompt(text)) {
return false;
}
runSawWorkspaceTrustPrompt = true;
return true;
};
const processCursorOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
activeCursorProcesses.set(processKey, cursorProcess);
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('📤 Cursor CLI stdout:', rawOutput);
const lines = rawOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
console.log('Parsed JSON response:', response);
console.log('📄 Parsed JSON response:', response);
// Handle different message types
switch (response.type) {
case 'system':
@@ -126,14 +86,14 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('Captured session ID:', capturedSessionId);
console.log('📝 Captured session ID:', capturedSessionId);
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeCursorProcesses.delete(processKey);
activeCursorProcesses.set(capturedSessionId, cursorProcess);
}
// Set session ID on writer (for API endpoint compatibility)
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
@@ -150,7 +110,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
}
// Send system info to frontend
ws.send({
type: 'cursor-system',
@@ -159,7 +119,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
break;
case 'user':
// Forward user message
ws.send({
@@ -168,12 +128,13 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
break;
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const textContent = response.message.content[0].text;
messageBuffer += textContent;
// Send as Claude-compatible format for frontend
ws.send({
type: 'claude-response',
@@ -188,14 +149,23 @@ async function spawnCursor(command, options = {}, ws) {
});
}
break;
case 'result':
// Session complete
console.log('Cursor session result:', response);
// Do not emit an extra content_block_stop here.
// The UI already finalizes the streaming message in cursor-result handling,
// and emitting both can produce duplicate assistant messages.
// Send final message if we have buffered content
if (messageBuffer) {
ws.send({
type: 'claude-response',
data: {
type: 'content_block_stop'
},
sessionId: capturedSessionId || sessionId || null
});
}
// Send completion event
ws.send({
type: 'cursor-result',
sessionId: capturedSessionId || sessionId,
@@ -203,7 +173,7 @@ async function spawnCursor(command, options = {}, ws) {
success: response.subtype === 'success'
});
break;
default:
// Forward any other message types
ws.send({
@@ -213,12 +183,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
} catch (parseError) {
console.log('Non-JSON response:', line);
if (shouldSuppressForTrustRetry(line)) {
return;
}
console.log('📄 Non-JSON response:', line);
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
@@ -226,106 +191,67 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
}
};
}
});
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString());
ws.send({
type: 'cursor-error',
error: data.toString(),
sessionId: capturedSessionId || sessionId || null
});
});
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
if (code === 0) {
resolve();
} else {
reject(new Error(`Cursor CLI exited with code ${code}`));
}
});
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Stream chunks can split JSON objects across packets; keep trailing partial line.
stdoutLineBuffer += rawOutput;
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
completeLines.forEach((line) => {
processCursorOutputLine(line.trim());
});
ws.send({
type: 'cursor-error',
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
const stderrText = data.toString();
console.error('Cursor CLI stderr:', stderrText);
if (shouldSuppressForTrustRetry(stderrText)) {
return;
}
ws.send({
type: 'cursor-error',
error: stderrText,
sessionId: capturedSessionId || sessionId || null
});
});
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Flush any final unterminated stdout line before completion handling.
if (stdoutLineBuffer.trim()) {
processCursorOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
if (
runSawWorkspaceTrustPrompt &&
code !== 0 &&
!hasRetriedWithTrust &&
!args.includes('--trust')
) {
hasRetriedWithTrust = true;
runCursorProcess([...args, '--trust'], 'trust-retry');
return;
}
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
if (code === 0) {
settleOnce(() => resolve());
} else {
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
}
});
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send({
type: 'cursor-error',
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
settleOnce(() => reject(error));
});
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
};
runCursorProcess(baseArgs, 'initial');
reject(error);
});
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
});
}
function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`Aborting Cursor session: ${sessionId}`);
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -59,15 +59,6 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
// Create database connection
const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
@@ -100,24 +91,35 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
console.log('Database migrations completed successfully');
} catch (error) {
@@ -376,84 +378,113 @@ const credentialsDb = {
}
};
// Session custom names database operations
const sessionNamesDb = {
// Set (insert or update) a custom session name
setName: (sessionId, provider, customName) => {
db.prepare(`
INSERT INTO session_names (session_id, provider, custom_name)
VALUES (?, ?, ?)
ON CONFLICT(session_id, provider)
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
`).run(sessionId, provider, customName);
},
// Get a single custom session name
getName: (sessionId, provider) => {
const row = db.prepare(
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
).get(sessionId, provider);
return row?.custom_name || null;
},
// Batch lookup — returns Map<sessionId, customName>
getNames: (sessionIds, provider) => {
if (!sessionIds.length) return new Map();
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db.prepare(
`SELECT session_id, custom_name FROM session_names
WHERE session_id IN (${placeholders}) AND provider = ?`
).all(...sessionIds, provider);
return new Map(rows.map(r => [r.session_id, r.custom_name]));
},
// Delete a custom session name
deleteName: (sessionId, provider) => {
return db.prepare(
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
).run(sessionId, provider).changes > 0;
const DEFAULT_NOTIFICATION_PREFERENCES = {
channels: {
inApp: false,
webPush: true
},
events: {
actionRequired: true,
stop: true,
error: true
}
};
// Apply custom session names from the database (overrides CLI-generated summaries)
function applyCustomSessionNames(sessions, provider) {
if (!sessions?.length) return;
try {
const ids = sessions.map(s => s.id);
const customNames = sessionNamesDb.getNames(ids, provider);
for (const session of sessions) {
const custom = customNames.get(session.id);
if (custom) session.summary = custom;
}
} catch (error) {
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
}
}
const normalizeNotificationPreferences = (value) => {
const source = value && typeof value === 'object' ? value : {};
// App config database operations
const appConfigDb = {
get: (key) => {
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush !== false
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false
}
};
};
const notificationPreferencesDb = {
getPreferences: (userId) => {
try {
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
return row?.value || null;
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
} catch (err) {
return null;
throw err;
}
},
set: (key, value) => {
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
updatePreferences: (userId, preferences) => {
try {
const normalized = normalizeNotificationPreferences(preferences);
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
} catch (err) {
throw err;
}
}
};
const pushSubscriptionsDb = {
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
try {
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
} catch (err) {
throw err;
}
},
getOrCreateJwtSecret: () => {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
getSubscriptions: (userId) => {
try {
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
} catch (err) {
throw err;
}
},
removeSubscription: (endpoint) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
} catch (err) {
throw err;
}
},
removeAllForUser: (userId) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
} catch (err) {
throw err;
}
return secret;
}
};
@@ -482,8 +513,7 @@ export {
userDb,
apiKeysDb,
credentialsDb,
sessionNamesDb,
applyCustomSessionNames,
appConfigDb,
notificationPreferencesDb,
pushSubscriptionsDb,
githubTokensDb // Backward compatibility
};
};

View File

@@ -51,22 +51,29 @@ CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user
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);
-- Session custom names (provider-agnostic display name overrides)
CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@@ -44,8 +44,8 @@ import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
@@ -64,14 +64,11 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { initializeDatabase } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
// File system watchers for provider project/session folders
const PROVIDER_WATCH_PATHS = [
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
@@ -326,7 +323,7 @@ const wss = new WebSocketServer({
// Make WebSocket server available to routes
app.locals.wss = wss;
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(cors());
app.use(express.json({
limit: '50mb',
type: (req) => {
@@ -391,9 +388,6 @@ app.use('/api/codex', authenticateToken, codexRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -500,7 +494,6 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
try {
const { limit = 5, offset = 0 } = req.query;
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
applyCustomSessionNames(result.sessions, 'claude');
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -549,7 +542,6 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
const { projectName, sessionId } = req.params;
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
await deleteSession(projectName, sessionId);
sessionNamesDb.deleteName(sessionId, 'claude');
console.log(`[API] Session ${sessionId} deleted successfully`);
res.json({ success: true });
} catch (error) {
@@ -558,32 +550,6 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
}
});
// Rename session endpoint
app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
try {
const { sessionId } = req.params;
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId || safeSessionId !== String(sessionId)) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
const { summary, provider } = req.body;
if (!summary || typeof summary !== 'string' || summary.trim() === '') {
return res.status(400).json({ error: 'Summary is required' });
}
if (summary.trim().length > 500) {
return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
}
if (!provider || !VALID_PROVIDERS.includes(provider)) {
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
}
sessionNamesDb.setName(safeSessionId, provider, summary.trim());
res.json({ success: true });
} catch (error) {
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
res.status(500).json({ error: error.message });
}
});
// Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
@@ -613,51 +579,6 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
}
});
// Search conversations content (SSE streaming)
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
const parsedLimit = Number.parseInt(String(req.query.limit), 10);
const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
if (query.length < 2) {
return res.status(400).json({ error: 'Query must be at least 2 characters' });
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
let closed = false;
const abortController = new AbortController();
req.on('close', () => { closed = true; abortController.abort(); });
try {
await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
if (closed) return;
if (projectResult) {
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
} else {
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
}
}, abortController.signal);
if (!closed) {
res.write(`event: done\ndata: {}\n\n`);
}
} catch (error) {
console.error('Error searching conversations:', error);
if (!closed) {
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
}
} finally {
if (!closed) {
res.end();
}
}
});
const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath;
if (inputPath === '~') {
@@ -964,436 +885,6 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
}
});
// ============================================================================
// FILE OPERATIONS API ENDPOINTS
// ============================================================================
/**
* Validate that a path is within the project root
* @param {string} projectRoot - The project root path
* @param {string} targetPath - The path to validate
* @returns {{ valid: boolean, resolved?: string, error?: string }}
*/
function validatePathInProject(projectRoot, targetPath) {
const resolved = path.isAbsolute(targetPath)
? path.resolve(targetPath)
: path.resolve(projectRoot, targetPath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return { valid: false, error: 'Path must be under project root' };
}
return { valid: true, resolved };
}
/**
* Validate filename - check for invalid characters
* @param {string} name - The filename to validate
* @returns {{ valid: boolean, error?: string }}
*/
function validateFilename(name) {
if (!name || !name.trim()) {
return { valid: false, error: 'Filename cannot be empty' };
}
// Check for invalid characters (Windows + Unix)
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
if (invalidChars.test(name)) {
return { valid: false, error: 'Filename contains invalid characters' };
}
// Check for reserved names (Windows)
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (reserved.test(name)) {
return { valid: false, error: 'Filename is a reserved name' };
}
// Check for dots only
if (/^\.+$/.test(name)) {
return { valid: false, error: 'Filename cannot be only dots' };
}
return { valid: true };
}
// POST /api/projects/:projectName/files/create - Create new file or directory
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: parentPath, type, name } = req.body;
// Validate input
if (!name || !type) {
return res.status(400).json({ error: 'Name and type are required' });
}
if (!['file', 'directory'].includes(type)) {
return res.status(400).json({ error: 'Type must be "file" or "directory"' });
}
const nameValidation = validateFilename(name);
if (!nameValidation.valid) {
return res.status(400).json({ error: nameValidation.error });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
// Build and validate target path
const targetDir = parentPath || '';
const targetPath = targetDir ? path.join(targetDir, name) : name;
const validation = validatePathInProject(projectRoot, targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolved;
// Check if already exists
try {
await fsPromises.access(resolvedPath);
return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
} catch {
// Doesn't exist, which is what we want
}
// Create file or directory
if (type === 'directory') {
await fsPromises.mkdir(resolvedPath, { recursive: false });
} else {
// Ensure parent directory exists
const parentDir = path.dirname(resolvedPath);
try {
await fsPromises.access(parentDir);
} catch {
await fsPromises.mkdir(parentDir, { recursive: true });
}
await fsPromises.writeFile(resolvedPath, '', 'utf8');
}
res.json({
success: true,
path: resolvedPath,
name,
type,
message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
});
} catch (error) {
console.error('Error creating file/directory:', error);
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else if (error.code === 'ENOENT') {
res.status(404).json({ error: 'Parent directory not found' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// PUT /api/projects/:projectName/files/rename - Rename file or directory
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { oldPath, newName } = req.body;
// Validate input
if (!oldPath || !newName) {
return res.status(400).json({ error: 'oldPath and newName are required' });
}
const nameValidation = validateFilename(newName);
if (!nameValidation.valid) {
return res.status(400).json({ error: nameValidation.error });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
// Validate old path
const oldValidation = validatePathInProject(projectRoot, oldPath);
if (!oldValidation.valid) {
return res.status(403).json({ error: oldValidation.error });
}
const resolvedOldPath = oldValidation.resolved;
// Check if old path exists
try {
await fsPromises.access(resolvedOldPath);
} catch {
return res.status(404).json({ error: 'File or directory not found' });
}
// Build and validate new path
const parentDir = path.dirname(resolvedOldPath);
const resolvedNewPath = path.join(parentDir, newName);
const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
if (!newValidation.valid) {
return res.status(403).json({ error: newValidation.error });
}
// Check if new path already exists
try {
await fsPromises.access(resolvedNewPath);
return res.status(409).json({ error: 'A file or directory with this name already exists' });
} catch {
// Doesn't exist, which is what we want
}
// Rename
await fsPromises.rename(resolvedOldPath, resolvedNewPath);
res.json({
success: true,
oldPath: resolvedOldPath,
newPath: resolvedNewPath,
newName,
message: 'Renamed successfully'
});
} catch (error) {
console.error('Error renaming file/directory:', error);
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File or directory not found' });
} else if (error.code === 'EXDEV') {
res.status(400).json({ error: 'Cannot move across different filesystems' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// DELETE /api/projects/:projectName/files - Delete file or directory
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: targetPath, type } = req.body;
// Validate input
if (!targetPath) {
return res.status(400).json({ error: 'Path is required' });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
// Validate path
const validation = validatePathInProject(projectRoot, targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolved;
// Check if path exists and get stats
let stats;
try {
stats = await fsPromises.stat(resolvedPath);
} catch {
return res.status(404).json({ error: 'File or directory not found' });
}
// Prevent deleting the project root itself
if (resolvedPath === path.resolve(projectRoot)) {
return res.status(403).json({ error: 'Cannot delete project root directory' });
}
// Delete based on type
if (stats.isDirectory()) {
await fsPromises.rm(resolvedPath, { recursive: true, force: true });
} else {
await fsPromises.unlink(resolvedPath);
}
res.json({
success: true,
path: resolvedPath,
type: stats.isDirectory() ? 'directory' : 'file',
message: 'Deleted successfully'
});
} catch (error) {
console.error('Error deleting file/directory:', error);
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File or directory not found' });
} else if (error.code === 'ENOTEMPTY') {
res.status(400).json({ error: 'Directory is not empty' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// POST /api/projects/:projectName/files/upload - Upload files
// Dynamic import of multer for file uploads
const uploadFilesHandler = async (req, res) => {
// Dynamic import of multer
const multer = (await import('multer')).default;
const uploadMiddleware = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
cb(null, os.tmpdir());
},
filename: (req, file, cb) => {
// Use a unique temp name, but preserve original name in file.originalname
// Note: file.originalname may contain path separators for folder uploads
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
// For temp file, just use a safe unique name without the path
cb(null, `upload-${uniqueSuffix}`);
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
files: 20 // Max 20 files at once
}
});
// Use multer middleware
uploadMiddleware.array('files', 20)(req, res, async (err) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
}
return res.status(500).json({ error: err.message });
}
try {
const { projectName } = req.params;
const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads)
let filePaths = [];
if (relativePaths) {
try {
filePaths = JSON.parse(relativePaths);
} catch (e) {
console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
}
}
console.log('[DEBUG] File upload request:', {
projectName,
targetPath: JSON.stringify(targetPath),
targetPathType: typeof targetPath,
filesCount: req.files?.length,
relativePaths: filePaths
});
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files provided' });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
console.log('[DEBUG] Project root:', projectRoot);
// Validate and resolve target path
// If targetPath is empty or '.', use project root directly
const targetDir = targetPath || '';
let resolvedTargetDir;
console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
if (!targetDir || targetDir === '.' || targetDir === './') {
// Empty path means upload to project root
resolvedTargetDir = path.resolve(projectRoot);
console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
} else {
const validation = validatePathInProject(projectRoot, targetDir);
if (!validation.valid) {
console.log('[DEBUG] Path validation failed:', validation.error);
return res.status(403).json({ error: validation.error });
}
resolvedTargetDir = validation.resolved;
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
}
// Ensure target directory exists
try {
await fsPromises.access(resolvedTargetDir);
} catch {
await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
}
// Move uploaded files from temp to target directory
const uploadedFiles = [];
console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
// Use relative path if provided (for folder uploads), otherwise use originalname
const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
const destPath = path.join(resolvedTargetDir, fileName);
// Validate destination path
const destValidation = validatePathInProject(projectRoot, destPath);
if (!destValidation.valid) {
console.log('[DEBUG] Destination validation failed for:', destPath);
// Clean up temp file
await fsPromises.unlink(file.path).catch(() => {});
continue;
}
// Ensure parent directory exists (for nested files from folder upload)
const parentDir = path.dirname(destPath);
try {
await fsPromises.access(parentDir);
} catch {
await fsPromises.mkdir(parentDir, { recursive: true });
}
// Move file (copy + unlink to handle cross-device scenarios)
await fsPromises.copyFile(file.path, destPath);
await fsPromises.unlink(file.path);
uploadedFiles.push({
name: fileName,
path: destPath,
size: file.size,
mimeType: file.mimetype
});
}
res.json({
success: true,
files: uploadedFiles,
targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
});
} catch (error) {
console.error('Error uploading files:', error);
// Clean up any remaining temp files
if (req.files) {
for (const file of req.files) {
await fsPromises.unlink(file.path).catch(() => {});
}
}
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: error.message });
}
}
});
};
app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
// WebSocket connection handler that routes based on URL path
wss.on('connection', (ws, request) => {
const url = request.url;
@@ -1406,7 +897,7 @@ wss.on('connection', (ws, request) => {
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (pathname === '/ws') {
handleChatConnection(ws);
handleChatConnection(ws, request);
} else {
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
@@ -1417,9 +908,10 @@ wss.on('connection', (ws, request) => {
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*/
class WebSocketWriter {
constructor(ws) {
constructor(ws, userId = null) {
this.ws = ws;
this.sessionId = null;
this.userId = userId;
this.isWebSocketWriter = true; // Marker for transport detection
}
@@ -1430,10 +922,6 @@ class WebSocketWriter {
}
}
updateWebSocket(newRawWs) {
this.ws = newRawWs;
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
@@ -1444,14 +932,14 @@ class WebSocketWriter {
}
// Handle chat WebSocket connections
function handleChatConnection(ws) {
function handleChatConnection(ws, request) {
console.log('[INFO] Chat WebSocket connected');
// Add to connected clients for project updates
connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
const writer = new WebSocketWriter(ws);
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
ws.on('message', async (message) => {
try {
@@ -1548,11 +1036,6 @@ function handleChatConnection(ws) {
} else {
// Use Claude Agents SDK
isActive = isClaudeSDKSessionActive(sessionId);
if (isActive) {
// Reconnect the session's writer to the new WebSocket so
// subsequent SDK output flows to the refreshed client.
reconnectSessionWriter(sessionId, ws);
}
}
writer.send({
@@ -1561,17 +1044,6 @@ function handleChatConnection(ws) {
provider,
isProcessing: isActive
});
} else if (data.type === 'get-pending-permissions') {
// Return pending permission requests for a session
const sessionId = data.sessionId;
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
const pending = getPendingApprovalsForSession(sessionId);
writer.send({
type: 'pending-permissions-response',
sessionId,
data: pending
});
}
} else if (data.type === 'get-active-sessions') {
// Get all currently active sessions
const activeSessions = {
@@ -1699,49 +1171,50 @@ function handleShellConnection(ws) {
}));
try {
// Validate projectPath — resolve to absolute and verify it exists
const resolvedProjectPath = path.resolve(projectPath);
try {
const stats = fs.statSync(resolvedProjectPath);
if (!stats.isDirectory()) {
throw new Error('Not a directory');
}
} catch (pathErr) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
return;
}
// Validate sessionId — only allow safe characters
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
// Build shell command — use cwd for project path (never interpolate into shell string)
// Prepare the shell command adapted to the platform and provider
let shellCommand;
if (isPlainShell) {
// Plain shell mode - run the initial command in the project directory
shellCommand = initialCommand;
} else if (provider === 'cursor') {
if (hasSession && sessionId) {
shellCommand = `cursor-agent --resume="${sessionId}"`;
// Plain shell mode - just run the initial command in the project directory
if (os.platform() === 'win32') {
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
} else {
shellCommand = 'cursor-agent';
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
}
} else if (provider === 'codex') {
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
// PowerShell syntax for fallback
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else if (provider === 'cursor') {
// Use cursor-agent command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `codex resume "${sessionId}" || codex`;
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
}
} else {
shellCommand = 'codex';
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `cd "${projectPath}" && cursor-agent`;
}
}
} else if (provider === 'codex') {
// Use codex command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
}
} else {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
} else {
shellCommand = `cd "${projectPath}" && codex`;
}
}
} else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
@@ -1752,32 +1225,41 @@ function handleShellConnection(ws) {
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
// Validate the looked-up CLI session ID too
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = null;
}
}
} catch (err) {
console.error('Failed to get Gemini CLI session ID:', err);
}
}
if (hasSession && resumeId) {
shellCommand = `${command} --resume "${resumeId}"`;
} else {
shellCommand = command;
}
} else {
// Claude (default provider)
const command = initialCommand || 'claude';
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
if (os.platform() === 'win32') {
if (hasSession && resumeId) {
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
} else {
shellCommand = `claude --resume "${sessionId}" || claude`;
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else {
shellCommand = command;
if (hasSession && resumeId) {
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
}
} else {
// Use claude command (default) or initialCommand if provided
const command = initialCommand || 'claude';
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else {
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
}
}
@@ -1796,7 +1278,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: resolvedProjectPath,
cwd: os.homedir(),
env: {
...process.env,
TERM: 'xterm-256color',
@@ -2202,7 +1684,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId || safeSessionId !== String(sessionId)) {
if (!safeSessionId) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
@@ -2500,6 +1982,9 @@ async function startServer() {
// Initialize authentication database
await initializeDatabase();
// Configure Web Push (VAPID keys)
configureWebPush();
// Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath);
@@ -2527,20 +2012,7 @@ async function startServer() {
// Start watching the projects folder for changes
await setupProjectsWatcher();
// Start server-side plugin processes for enabled plugins
startEnabledPluginServers().catch(err => {
console.error('[Plugins] Error during startup:', err.message);
});
});
// Clean up plugin processes on shutdown
const shutdownPlugins = async () => {
await stopAllPlugins();
process.exit(0);
};
process.on('SIGTERM', () => void shutdownPlugins());
process.on('SIGINT', () => void shutdownPlugins());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);

View File

@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken';
import { userDb, appConfigDb } from '../database/db.js';
import { userDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js';
// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
// Optional API key middleware
const validateApiKey = (req, res, next) => {
@@ -58,16 +58,6 @@ const authenticateToken = async (req, res, next) => {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
// Auto-refresh: if token is past halfway through its lifetime, issue a new one
if (decoded.exp && decoded.iat) {
const now = Math.floor(Date.now() / 1000);
const halfLife = (decoded.exp - decoded.iat) / 2;
if (now > decoded.iat + halfLife) {
const newToken = generateToken(user);
res.setHeader('X-Refreshed-Token', newToken);
}
}
req.user = user;
next();
} catch (error) {
@@ -76,15 +66,15 @@ const authenticateToken = async (req, res, next) => {
}
};
// Generate JWT token
// Generate JWT token (never expires)
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
{
userId: user.id,
username: user.username
},
JWT_SECRET,
{ expiresIn: '7d' }
JWT_SECRET
// No expiration - token lasts forever
);
};
@@ -95,7 +85,7 @@ const authenticateWebSocket = (token) => {
try {
const user = userDb.getFirstUser();
if (user) {
return { userId: user.id, username: user.username };
return { id: user.id, userId: user.id, username: user.username };
}
return null;
} catch (error) {
@@ -111,12 +101,10 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user actually exists in database (matches REST authenticateToken behavior)
const user = userDb.getUserById(decoded.userId);
if (!user) {
return null;
}
return { userId: user.id, username: user.username };
return {
...decoded,
id: decoded.userId
};
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
@@ -129,4 +117,4 @@ export {
generateToken,
authenticateWebSocket,
JWT_SECRET
};
};

View File

@@ -66,7 +66,6 @@ import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import os from 'os';
import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';
// Import TaskMaster detection functions
async function detectTaskMasterFolder(projectPath) {
@@ -459,7 +458,6 @@ async function getProjects(progressCallback = null) {
total: 0
};
}
applyCustomSessionNames(project.sessions, 'claude');
// Also fetch Cursor sessions for this project
try {
@@ -468,7 +466,6 @@ async function getProjects(progressCallback = null) {
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
project.cursorSessions = [];
}
applyCustomSessionNames(project.cursorSessions, 'cursor');
// Also fetch Codex sessions for this project
try {
@@ -479,20 +476,14 @@ async function getProjects(progressCallback = null) {
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
project.codexSessions = [];
}
applyCustomSessionNames(project.codexSessions, 'codex');
// Also fetch Gemini sessions for this project (UI + CLI)
// Also fetch Gemini sessions for this project
try {
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
const cliSessions = await getGeminiCliSessions(actualProjectDir);
const uiIds = new Set(uiSessions.map(s => s.id));
const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
project.geminiSessions = mergedGemini;
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
} catch (e) {
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
project.geminiSessions = [];
}
applyCustomSessionNames(project.geminiSessions, 'gemini');
// Add TaskMaster detection
try {
@@ -576,7 +567,6 @@ async function getProjects(progressCallback = null) {
} catch (e) {
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
}
applyCustomSessionNames(project.cursorSessions, 'cursor');
// Try to fetch Codex sessions for manual projects too
try {
@@ -586,18 +576,13 @@ async function getProjects(progressCallback = null) {
} catch (e) {
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
}
applyCustomSessionNames(project.codexSessions, 'codex');
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
// Try to fetch Gemini sessions for manual projects too
try {
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
const cliSessions = await getGeminiCliSessions(actualProjectDir);
const uiIds = new Set(uiSessions.map(s => s.id));
project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
} catch (e) {
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
}
applyCustomSessionNames(project.geminiSessions, 'gemini');
// Add TaskMaster detection for manual projects
try {
@@ -1086,13 +1071,10 @@ async function renameProject(projectName, newDisplayName) {
if (!newDisplayName || newDisplayName.trim() === '') {
// Remove custom name if empty, will fall back to auto-generated
if (config[projectName]) {
delete config[projectName].displayName;
}
delete config[projectName];
} else {
// Set custom display name, preserving other properties (manuallyAdded, originalPath)
// Set custom display name
config[projectName] = {
...config[projectName],
displayName: newDisplayName.trim()
};
}
@@ -1497,23 +1479,6 @@ async function getCodexSessions(projectPath, options = {}) {
}
}
function isVisibleCodexUserMessage(payload) {
if (!payload || payload.type !== 'user_message') {
return false;
}
// Codex logs internal context (environment, instructions) as non-plain user_message kinds.
if (payload.kind && payload.kind !== 'plain') {
return false;
}
if (typeof payload.message !== 'string' || payload.message.trim().length === 0) {
return false;
}
return true;
}
// Parse a Codex session JSONL file to extract metadata
async function parseCodexSessionFile(filePath) {
try {
@@ -1549,8 +1514,8 @@ async function parseCodexSessionFile(filePath) {
};
}
// Count visible user messages and extract summary from the latest plain user input.
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
// Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++;
if (entry.payload.message) {
lastUserMessage = entry.payload.message;
@@ -1657,36 +1622,25 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
};
}
}
// Use event_msg.user_message for user-visible inputs.
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
messages.push({
type: 'user',
timestamp: entry.timestamp,
message: {
role: 'user',
content: entry.payload.message
}
});
}
// response_item.message may include internal prompts for non-assistant roles.
// Keep only assistant output from response_item.
if (
entry.type === 'response_item' &&
entry.payload?.type === 'message' &&
entry.payload.role === 'assistant'
) {
// Extract messages from response_item
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
const content = entry.payload.content;
const role = entry.payload.role || 'assistant';
const textContent = extractText(content);
// Skip system context messages (environment_context)
if (textContent?.includes('<environment_context>')) {
continue;
}
// Only add if there's actual content
if (textContent?.trim()) {
messages.push({
type: 'assistant',
type: role === 'user' ? 'user' : 'assistant',
timestamp: entry.timestamp,
message: {
role: 'assistant',
role: role,
content: textContent
}
});
@@ -1869,675 +1823,6 @@ async function deleteCodexSession(sessionId) {
}
}
async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
const safeQuery = typeof query === 'string' ? query.trim() : '';
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
const results = [];
let totalMatches = 0;
const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery };
const isAborted = () => signal?.aborted === true;
const isSystemMessage = (textContent) => {
return typeof textContent === 'string' && (
textContent.startsWith('<command-name>') ||
textContent.startsWith('<command-message>') ||
textContent.startsWith('<command-args>') ||
textContent.startsWith('<local-command-stdout>') ||
textContent.startsWith('<system-reminder>') ||
textContent.startsWith('Caveat:') ||
textContent.startsWith('This session is being continued from a previous') ||
textContent.startsWith('Invalid API key') ||
textContent.includes('{"subtasks":') ||
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') ||
textContent === 'Warmup'
);
};
const extractText = (content) => {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter(part => part.type === 'text' && part.text)
.map(part => part.text)
.join(' ');
}
return '';
};
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordPatterns = words.map(w => new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u'));
const allWordsMatch = (textLower) => {
return wordPatterns.every(p => p.test(textLower));
};
const buildSnippet = (text, textLower, snippetLen = 150) => {
let firstIndex = -1;
let firstWordLen = 0;
for (const w of words) {
const re = new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u');
const m = re.exec(textLower);
if (m && (firstIndex === -1 || m.index < firstIndex)) {
firstIndex = m.index;
firstWordLen = w.length;
}
}
if (firstIndex === -1) firstIndex = 0;
const halfLen = Math.floor(snippetLen / 2);
let start = Math.max(0, firstIndex - halfLen);
let end = Math.min(text.length, firstIndex + halfLen + firstWordLen);
let snippet = text.slice(start, end).replace(/\n/g, ' ');
const prefix = start > 0 ? '...' : '';
const suffix = end < text.length ? '...' : '';
snippet = prefix + snippet + suffix;
const snippetLower = snippet.toLowerCase();
const highlights = [];
for (const word of words) {
const re = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'gu');
let match;
while ((match = re.exec(snippetLower)) !== null) {
highlights.push({ start: match.index, end: match.index + word.length });
}
}
highlights.sort((a, b) => a.start - b.start);
const merged = [];
for (const h of highlights) {
const last = merged[merged.length - 1];
if (last && h.start <= last.end) {
last.end = Math.max(last.end, h.end);
} else {
merged.push({ ...h });
}
}
return { snippet, highlights: merged };
};
try {
await fs.access(claudeDir);
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
const projectDirs = entries.filter(e => e.isDirectory());
let scannedProjects = 0;
const totalProjects = projectDirs.length;
for (const projectEntry of projectDirs) {
if (totalMatches >= safeLimit || isAborted()) break;
const projectName = projectEntry.name;
const projectDir = path.join(claudeDir, projectName);
const displayName = config[projectName]?.displayName
|| await generateDisplayName(projectName);
let files;
try {
files = await fs.readdir(projectDir);
} catch {
continue;
}
const jsonlFiles = files.filter(
file => file.endsWith('.jsonl') && !file.startsWith('agent-')
);
const projectResult = {
projectName,
projectDisplayName: displayName,
sessions: []
};
for (const file of jsonlFiles) {
if (totalMatches >= safeLimit || isAborted()) break;
const filePath = path.join(projectDir, file);
const sessionMatches = new Map();
const sessionSummaries = new Map();
const pendingSummaries = new Map();
const sessionLastMessages = new Map();
let currentSessionId = null;
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (totalMatches >= safeLimit || isAborted()) break;
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.sessionId) {
currentSessionId = entry.sessionId;
}
if (entry.type === 'summary' && entry.summary) {
const sid = entry.sessionId || currentSessionId;
if (sid) {
sessionSummaries.set(sid, entry.summary);
} else if (entry.leafUuid) {
pendingSummaries.set(entry.leafUuid, entry.summary);
}
}
// Apply pending summary via parentUuid
if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) {
const pending = pendingSummaries.get(entry.parentUuid);
if (pending) sessionSummaries.set(currentSessionId, pending);
}
// Track last user/assistant message for fallback title
if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) {
const role = entry.message.role;
if (role === 'user' || role === 'assistant') {
const text = extractText(entry.message.content);
if (text && !isSystemMessage(text)) {
if (!sessionLastMessages.has(currentSessionId)) {
sessionLastMessages.set(currentSessionId, {});
}
const msgs = sessionLastMessages.get(currentSessionId);
if (role === 'user') msgs.user = text;
else msgs.assistant = text;
}
}
}
if (!entry.message?.content) continue;
if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue;
if (entry.isApiErrorMessage) continue;
const text = extractText(entry.message.content);
if (!text || isSystemMessage(text)) continue;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', '');
if (!sessionMatches.has(sessionId)) {
sessionMatches.set(sessionId, []);
}
const matches = sessionMatches.get(sessionId);
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({
role: entry.message.role,
snippet,
highlights,
timestamp: entry.timestamp || null,
provider: 'claude',
messageUuid: entry.uuid || null
});
totalMatches++;
}
}
} catch {
continue;
}
for (const [sessionId, matches] of sessionMatches) {
projectResult.sessions.push({
sessionId,
provider: 'claude',
sessionSummary: sessionSummaries.get(sessionId) || (() => {
const msgs = sessionLastMessages.get(sessionId);
const lastMsg = msgs?.user || msgs?.assistant;
return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session';
})(),
matches
});
}
}
// Search Codex sessions for this project
try {
const actualProjectDir = await extractProjectDirectory(projectName);
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
await searchCodexSessionsForProject(
actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage,
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted
);
}
} catch {
// Skip codex search errors
}
// Search Gemini sessions for this project
try {
const actualProjectDir = await extractProjectDirectory(projectName);
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
await searchGeminiSessionsForProject(
actualProjectDir, projectResult, words, allWordsMatch,
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }
);
}
} catch {
// Skip gemini search errors
}
scannedProjects++;
if (projectResult.sessions.length > 0) {
results.push(projectResult);
if (onProjectResult) {
onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects });
}
} else if (onProjectResult && scannedProjects % 10 === 0) {
onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects });
}
}
} catch {
// claudeDir doesn't exist
}
return { results, totalMatches, query: safeQuery };
}
async function searchCodexSessionsForProject(
projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage,
buildSnippet, limit, getTotalMatches, addMatches, isAborted
) {
const normalizedProjectPath = normalizeComparablePath(projectPath);
if (!normalizedProjectPath) return;
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
try {
await fs.access(codexSessionsDir);
} catch {
return;
}
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
for (const filePath of jsonlFiles) {
if (getTotalMatches() >= limit || isAborted()) break;
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
// First pass: read session_meta to check project path match
let sessionMeta = null;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'session_meta' && entry.payload) {
sessionMeta = entry.payload;
break;
}
} catch { continue; }
}
// Skip sessions that don't belong to this project
if (!sessionMeta) continue;
const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd);
if (sessionProjectPath !== normalizedProjectPath) continue;
// Second pass: re-read file to find matching messages
const fileStream2 = fsSync.createReadStream(filePath);
const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity });
let lastUserMessage = null;
const matches = [];
for await (const line of rl2) {
if (getTotalMatches() >= limit || isAborted()) break;
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
let text = null;
let role = null;
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) {
text = entry.payload.message;
role = 'user';
lastUserMessage = text;
} else if (entry.type === 'response_item' && entry.payload?.type === 'message') {
const contentParts = entry.payload.content || [];
if (entry.payload.role === 'user') {
text = contentParts
.filter(p => p.type === 'input_text' && p.text)
.map(p => p.text)
.join(' ');
role = 'user';
if (text) lastUserMessage = text;
} else if (entry.payload.role === 'assistant') {
text = contentParts
.filter(p => p.type === 'output_text' && p.text)
.map(p => p.text)
.join(' ');
role = 'assistant';
}
}
if (!text || !role) continue;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' });
addMatches(1);
}
}
if (matches.length > 0) {
projectResult.sessions.push({
sessionId: sessionMeta.id,
provider: 'codex',
sessionSummary: lastUserMessage
? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage)
: 'Codex Session',
matches
});
}
} catch {
continue;
}
}
}
async function searchGeminiSessionsForProject(
projectPath, projectResult, words, allWordsMatch,
buildSnippet, limit, getTotalMatches, addMatches
) {
// 1) Search in-memory sessions (created via UI)
for (const [sessionId, session] of sessionManager.sessions) {
if (getTotalMatches() >= limit) break;
if (session.projectPath !== projectPath) continue;
const matches = [];
for (const msg of session.messages) {
if (getTotalMatches() >= limit) break;
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const text = typeof msg.content === 'string' ? msg.content
: Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ')
: '';
if (!text) continue;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({
role: msg.role, snippet, highlights,
timestamp: msg.timestamp ? msg.timestamp.toISOString() : null,
provider: 'gemini'
});
addMatches(1);
}
}
if (matches.length > 0) {
const firstUserMsg = session.messages.find(m => m.role === 'user');
const summary = firstUserMsg?.content
? (typeof firstUserMsg.content === 'string'
? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content)
: 'Gemini Session')
: 'Gemini Session';
projectResult.sessions.push({
sessionId,
provider: 'gemini',
sessionSummary: summary,
matches
});
}
}
// 2) Search Gemini CLI sessions on disk (~/.gemini/tmp/<project>/chats/*.json)
const normalizedProjectPath = normalizeComparablePath(projectPath);
if (!normalizedProjectPath) return;
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
try {
await fs.access(geminiTmpDir);
} catch {
return;
}
const trackedSessionIds = new Set();
for (const [sid] of sessionManager.sessions) {
trackedSessionIds.add(sid);
}
let projectDirs;
try {
projectDirs = await fs.readdir(geminiTmpDir);
} catch {
return;
}
for (const projectDir of projectDirs) {
if (getTotalMatches() >= limit) break;
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
let projectRoot;
try {
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
} catch {
continue;
}
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const chatFile of chatFiles) {
if (getTotalMatches() >= limit) break;
if (!chatFile.endsWith('.json')) continue;
try {
const filePath = path.join(chatsDir, chatFile);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
if (!session.messages || !Array.isArray(session.messages)) continue;
const cliSessionId = session.sessionId || chatFile.replace('.json', '');
if (trackedSessionIds.has(cliSessionId)) continue;
const matches = [];
let firstUserText = null;
for (const msg of session.messages) {
if (getTotalMatches() >= limit) break;
const role = msg.type === 'user' ? 'user'
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
: null;
if (!role) continue;
let text = '';
if (typeof msg.content === 'string') {
text = msg.content;
} else if (Array.isArray(msg.content)) {
text = msg.content
.filter(p => p.text)
.map(p => p.text)
.join(' ');
}
if (!text) continue;
if (role === 'user' && !firstUserText) firstUserText = text;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({
role, snippet, highlights,
timestamp: msg.timestamp || null,
provider: 'gemini'
});
addMatches(1);
}
}
if (matches.length > 0) {
const summary = firstUserText
? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText)
: 'Gemini CLI Session';
projectResult.sessions.push({
sessionId: cliSessionId,
provider: 'gemini',
sessionSummary: summary,
matches
});
}
} catch {
continue;
}
}
}
}
async function getGeminiCliSessions(projectPath) {
const normalizedProjectPath = normalizeComparablePath(projectPath);
if (!normalizedProjectPath) return [];
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
try {
await fs.access(geminiTmpDir);
} catch {
return [];
}
const sessions = [];
let projectDirs;
try {
projectDirs = await fs.readdir(geminiTmpDir);
} catch {
return [];
}
for (const projectDir of projectDirs) {
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
let projectRoot;
try {
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
} catch {
continue;
}
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const chatFile of chatFiles) {
if (!chatFile.endsWith('.json')) continue;
try {
const filePath = path.join(chatsDir, chatFile);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
if (!session.messages || !Array.isArray(session.messages)) continue;
const sessionId = session.sessionId || chatFile.replace('.json', '');
const firstUserMsg = session.messages.find(m => m.type === 'user');
let summary = 'Gemini CLI Session';
if (firstUserMsg) {
const text = Array.isArray(firstUserMsg.content)
? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')
: (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');
if (text) {
summary = text.length > 50 ? text.substring(0, 50) + '...' : text;
}
}
sessions.push({
id: sessionId,
summary,
messageCount: session.messages.length,
lastActivity: session.lastUpdated || session.startTime || null,
provider: 'gemini'
});
} catch {
continue;
}
}
}
return sessions.sort((a, b) =>
new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
);
}
async function getGeminiCliSessionMessages(sessionId) {
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
let projectDirs;
try {
projectDirs = await fs.readdir(geminiTmpDir);
} catch {
return [];
}
for (const projectDir of projectDirs) {
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const chatFile of chatFiles) {
if (!chatFile.endsWith('.json')) continue;
try {
const filePath = path.join(chatsDir, chatFile);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
const fileSessionId = session.sessionId || chatFile.replace('.json', '');
if (fileSessionId !== sessionId) continue;
return (session.messages || []).map(msg => {
const role = msg.type === 'user' ? 'user'
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
: msg.type;
let content = '';
if (typeof msg.content === 'string') {
content = msg.content;
} else if (Array.isArray(msg.content)) {
content = msg.content.filter(p => p.text).map(p => p.text).join('\n');
}
return {
type: 'message',
message: { role, content },
timestamp: msg.timestamp || null
};
});
} catch {
continue;
}
}
}
return [];
}
export {
getProjects,
getSessions,
@@ -2554,8 +1839,5 @@ export {
clearProjectDirectoryCache,
getCodexSessions,
getCodexSessionMessages,
deleteCodexSession,
getGeminiCliSessions,
getGeminiCliSessionMessages,
searchConversations
deleteCodexSession
};

View File

@@ -14,14 +14,13 @@ router.get('/claude/status', async (req, res) => {
return res.json({
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method // 'api_key' or 'credentials_file'
method: 'credentials_file'
});
}
return res.json({
authenticated: false,
email: null,
method: null,
error: credentialsResult.error || 'Not authenticated'
});
@@ -30,7 +29,6 @@ router.get('/claude/status', async (req, res) => {
res.status(500).json({
authenticated: false,
email: null,
method: null,
error: error.message
});
}
@@ -117,20 +115,6 @@ router.get('/gemini/status', async (req, res) => {
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/
async function checkClaudeCredentials() {
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
// This is the standard authentication method used by Claude CLI after running
// 'claude /login' or 'claude setup-token' commands.
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
@@ -143,22 +127,19 @@ async function checkClaudeCredentials() {
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
email: creds.email || creds.user || null
};
}
}
return {
authenticated: false,
email: null,
method: null
email: null
};
} catch (error) {
return {
authenticated: false,
email: null,
method: null
email: null
};
}
}

View File

@@ -5,7 +5,6 @@ import path from 'path';
import os from 'os';
import TOML from '@iarna/toml';
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
const router = express.Router();
@@ -60,7 +59,6 @@ router.get('/sessions', async (req, res) => {
}
const sessions = await getCodexSessions(projectPath);
applyCustomSessionNames(sessions, 'codex');
res.json({ success: true, sessions });
} catch (error) {
console.error('Error fetching Codex sessions:', error);
@@ -90,7 +88,6 @@ router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
await deleteCodexSession(sessionId);
sessionNamesDb.deleteName(sessionId, 'codex');
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);

View File

@@ -3,8 +3,8 @@ import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import matter from 'gray-matter';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { parseFrontmatter } from '../utils/frontmatter.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -38,7 +38,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
// Parse markdown file for metadata
try {
const content = await fs.readFile(fullPath, 'utf8');
const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
const { data: frontmatter, content: commandContent } = matter(content);
// Calculate relative path from baseDir for command name
const relativePath = path.relative(baseDir, fullPath);
@@ -475,7 +475,7 @@ router.post('/load', async (req, res) => {
// Read and parse the command file
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontmatter(content);
const { data: metadata, content: commandContent } = matter(content);
res.json({
path: commandPath,
@@ -560,7 +560,7 @@ router.post('/execute', async (req, res) => {
}
}
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontmatter(content);
const { data: metadata, content: commandContent } = matter(content);
// Basic argument replacement (will be enhanced in command parser utility)
let processedContent = commandContent;

View File

@@ -7,7 +7,6 @@ import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { applyCustomSessionNames } from '../database/db.js';
const router = express.Router();
@@ -561,10 +560,8 @@ router.get('/sessions', async (req, res) => {
return new Date(b.createdAt) - new Date(a.createdAt);
});
applyCustomSessionNames(sessions, 'cursor');
res.json({
success: true,
res.json({
success: true,
sessions: sessions,
cwdId: cwdId,
path: cursorChatsPath

View File

@@ -1,7 +1,5 @@
import express from 'express';
import sessionManager from '../sessionManager.js';
import { sessionNamesDb } from '../database/db.js';
import { getGeminiCliSessionMessages } from '../projects.js';
const router = express.Router();
@@ -13,12 +11,7 @@ router.get('/sessions/:sessionId/messages', async (req, res) => {
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
}
let messages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (messages.length === 0) {
messages = await getGeminiCliSessionMessages(sessionId);
}
const messages = sessionManager.getSessionMessages(sessionId);
res.json({
success: true,
@@ -43,7 +36,6 @@ router.delete('/sessions/:sessionId', async (req, res) => {
}
await sessionManager.deleteSession(sessionId);
sessionNamesDb.deleteName(sessionId, 'gemini');
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);

View File

@@ -1,5 +1,6 @@
import express from 'express';
import { spawn } from 'child_process';
import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js';
@@ -7,7 +8,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
const router = express.Router();
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
const execAsync = promisify(exec);
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
@@ -46,71 +47,15 @@ function spawnAsync(command, args, options = {}) {
});
}
// Input validation helpers (defense-in-depth)
function validateCommitRef(commit) {
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
throw new Error('Invalid commit reference');
}
return commit;
}
function validateBranchName(branch) {
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
throw new Error('Invalid branch name');
}
return branch;
}
function validateFilePath(file, projectPath) {
if (!file || file.includes('\0')) {
throw new Error('Invalid file path');
}
// Prevent path traversal: resolve the file relative to the project root
// and ensure the result stays within the project directory
if (projectPath) {
const resolved = path.resolve(projectPath, file);
const normalizedRoot = path.resolve(projectPath) + path.sep;
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
throw new Error('Invalid file path: path traversal detected');
}
}
return file;
}
function validateRemoteName(remote) {
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
throw new Error('Invalid remote name');
}
return remote;
}
function validateProjectPath(projectPath) {
if (!projectPath || projectPath.includes('\0')) {
throw new Error('Invalid project path');
}
const resolved = path.resolve(projectPath);
// Must be an absolute path after resolution
if (!path.isAbsolute(resolved)) {
throw new Error('Invalid project path: must be absolute');
}
// Block obviously dangerous paths
if (resolved === '/' || resolved === path.sep) {
throw new Error('Invalid project path: root directory not allowed');
}
return resolved;
}
// Helper function to get the actual project path from the encoded project name
async function getActualProjectPath(projectName) {
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
return await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
throw new Error(`Unable to resolve project path for "${projectName}"`);
// Fallback to the old method
return projectName.replace(/-/g, '/');
}
return validateProjectPath(projectPath);
}
// Helper function to strip git diff headers
@@ -153,140 +98,19 @@ async function validateGitRepository(projectPath) {
try {
// Allow any directory that is inside a work tree (repo root or nested folder).
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
if (!isInsideWorkTree) {
throw new Error('Not inside a git work tree');
}
// Ensure git can resolve the repository root for this directory.
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
} catch {
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
}
}
function getGitErrorDetails(error) {
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
}
function isMissingHeadRevisionError(error) {
const errorDetails = getGitErrorDetails(error).toLowerCase();
return errorDetails.includes('unknown revision')
|| errorDetails.includes('ambiguous argument')
|| errorDetails.includes('needed a single revision')
|| errorDetails.includes('bad revision');
}
async function getCurrentBranchName(projectPath) {
try {
// symbolic-ref works even when the repository has no commits.
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
const branchName = stdout.trim();
if (branchName) {
return branchName;
}
} catch (error) {
// Fall back to rev-parse for detached HEAD and older git edge cases.
}
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
return stdout.trim();
}
async function repositoryHasCommits(projectPath) {
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
return true;
} catch (error) {
if (isMissingHeadRevisionError(error)) {
return false;
}
throw error;
}
}
async function getRepositoryRootPath(projectPath) {
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
return stdout.trim();
}
function normalizeRepositoryRelativeFilePath(filePath) {
return String(filePath)
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
.trim();
}
function parseStatusFilePaths(statusOutput) {
return statusOutput
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim())
.map((line) => {
const statusPath = line.substring(3);
const renamedFilePath = statusPath.split(' -> ')[1];
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
})
.filter(Boolean);
}
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
const candidates = [normalizedFilePath];
if (
projectRelativePath
&& projectRelativePath !== '.'
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
) {
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
}
return Array.from(new Set(candidates.filter(Boolean)));
}
async function resolveRepositoryFilePath(projectPath, filePath) {
validateFilePath(filePath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
for (const candidateFilePath of candidateFilePaths) {
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
if (stdout.trim()) {
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePath,
};
}
}
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
if (!normalizedFilePath.includes('/')) {
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
const suffixMatches = changedFilePaths.filter(
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
);
if (suffixMatches.length === 1) {
return {
repositoryRootPath,
repositoryRelativeFilePath: suffixMatches[0],
};
}
}
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePaths[0],
};
}
// Get git status for a project
router.get('/status', async (req, res) => {
const { project } = req.query;
@@ -301,11 +125,24 @@ router.get('/status', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath);
// Get current branch - handle case where there are no commits yet
let branch = 'main';
let hasCommits = true;
try {
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
branch = branchOutput.trim();
} catch (error) {
// No HEAD exists - repository has no commits yet
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
hasCommits = false;
branch = 'main';
} else {
throw error;
}
}
// Get git status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
const modified = [];
const added = [];
@@ -363,65 +200,44 @@ router.get('/diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is untracked or deleted
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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 filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// For directories, show a simple message
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
} else {
const fileContent = await fs.readFile(filePath, 'utf-8');
const lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\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 spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
const lines = fileContent.split('\n');
diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\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 spawnAsync(
'git',
['diff', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
if (unstagedDiff) {
// Show unstaged changes if they exist
diff = stripDiffHeaders(unstagedDiff);
} else {
// If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await spawnAsync(
'git',
['diff', '--cached', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
diff = stripDiffHeaders(stagedDiff) || '';
}
}
@@ -447,17 +263,8 @@ router.get('/file-with-diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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');
@@ -466,16 +273,12 @@ router.get('/file-with-diff', async (req, res) => {
if (isDeleted) {
// For deleted files, get content from HEAD
const { stdout: headContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor
} else {
// Get current file content
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
@@ -488,11 +291,7 @@ router.get('/file-with-diff', async (req, res) => {
if (!isUntracked) {
// Get the old content from HEAD for tracked files
try {
const { stdout: headContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
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)
@@ -529,17 +328,17 @@ router.post('/initial-commit', async (req, res) => {
// Check if there are already commits
try {
await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
await execAsync('git rev-parse HEAD', { cwd: projectPath });
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
} catch (error) {
// No HEAD - this is good, we can create initial commit
}
// Add all files
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
await execAsync('git add .', { cwd: projectPath });
// Create initial commit
const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
} catch (error) {
@@ -570,16 +369,14 @@ router.post('/commit', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Stage selected files
for (const file of files) {
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
await execAsync(`git add "${file}"`, { cwd: projectPath });
}
// Commit with message
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -588,53 +385,6 @@ router.post('/commit', async (req, res) => {
}
});
// Revert latest local commit (keeps changes staged)
router.post('/revert-local-commit', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
} catch (error) {
return res.status(400).json({
error: 'No local commit to revert',
details: 'This repository has no commit yet.',
});
}
try {
// Soft reset rewinds one commit while preserving all file changes in the index.
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
} catch (error) {
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
const isInitialCommit = errorDetails.includes('HEAD~1') &&
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
if (!isInitialCommit) {
throw error;
}
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
}
res.json({
success: true,
output: 'Latest local commit reverted successfully. Changes were kept staged.',
});
} catch (error) {
console.error('Git revert local commit error:', error);
res.status(500).json({ error: error.message });
}
});
// Get list of branches
router.get('/branches', async (req, res) => {
const { project } = req.query;
@@ -650,7 +400,7 @@ router.get('/branches', async (req, res) => {
await validateGitRepository(projectPath);
// Get all branches
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
// Parse branches
const branches = stdout
@@ -689,8 +439,7 @@ router.post('/checkout', async (req, res) => {
const projectPath = await getActualProjectPath(project);
// Checkout the branch
validateBranchName(branch);
const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -711,8 +460,7 @@ router.post('/create-branch', async (req, res) => {
const projectPath = await getActualProjectPath(project);
// Create and checkout new branch
validateBranchName(branch);
const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -761,8 +509,8 @@ router.get('/commits', async (req, res) => {
// Get stats for each commit
for (const commit of commits) {
try {
const { stdout: stats } = await spawnAsync(
'git', ['show', '--stat', '--format=', commit.hash],
const { stdout: stats } = await execAsync(
`git show --stat --format='' ${commit.hash}`,
{ cwd: projectPath }
);
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
@@ -788,22 +536,14 @@ router.get('/commit-diff', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
// Validate commit reference (defense-in-depth)
validateCommitRef(commit);
// Get diff for the commit
const { stdout } = await spawnAsync(
'git', ['show', commit],
const { stdout } = await execAsync(
`git show ${commit}`,
{ cwd: projectPath }
);
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
const diff = isTruncated
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
: stdout;
res.json({ diff, isTruncated });
res.json({ diff: stdout });
} catch (error) {
console.error('Git commit diff error:', error);
res.json({ error: error.message });
@@ -825,20 +565,17 @@ router.post('/generate-commit-message', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Get diff for selected files
let diffContext = '';
for (const file of files) {
try {
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const { stdout } = await spawnAsync(
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath }
const { stdout } = await execAsync(
`git diff HEAD -- "${file}"`,
{ cwd: projectPath }
);
if (stdout) {
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
diffContext += `\n--- ${file} ---\n${stdout}`;
}
} catch (error) {
console.error(`Error getting diff for ${file}:`, error);
@@ -850,15 +587,14 @@ router.post('/generate-commit-message', async (req, res) => {
// Try to get content of untracked files
for (const file of files) {
try {
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (!stats.isDirectory()) {
const content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
} else {
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
diffContext += `\n--- ${file} (new directory) ---\n`;
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
@@ -1027,51 +763,44 @@ router.get('/remote-status', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath);
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
const hasRemote = remotes.length > 0;
const fallbackRemoteName = hasRemote
? (remotes.includes('origin') ? 'origin' : remotes[0])
: null;
// Repositories initialized with `git init` can have a branch but no commits.
// Return a non-error state so the UI can show the initial-commit workflow.
if (!hasCommits) {
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName: fallbackRemoteName,
ahead: 0,
behind: 0,
isUpToDate: false,
message: 'Repository has no commits yet'
});
}
// Get current branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
// Check if there's a remote tracking branch (smart detection)
let trackingBranch;
let remoteName;
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
return res.json({
// No upstream branch configured - but check if we have remotes
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
}
} catch (remoteError) {
// No remotes configured
}
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName: fallbackRemoteName,
remoteName,
message: 'No remote tracking branch configured'
});
}
// Get ahead/behind counts
const { stdout: countOutput } = await spawnAsync(
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
const { stdout: countOutput } = await execAsync(
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
{ cwd: projectPath }
);
@@ -1106,20 +835,20 @@ router.post('/fetch', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const branch = await getCurrentBranchName(projectPath);
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
remoteName = stdout.trim().split('/')[0]; // Extract remote name
} catch (error) {
// No upstream, try to fetch from origin anyway
console.log('No upstream configured, using origin as fallback');
}
validateRemoteName(remoteName);
const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
} catch (error) {
console.error('Git fetch error:', error);
@@ -1147,12 +876,13 @@ router.post('/pull', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const branch = await getCurrentBranchName(projectPath);
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
@@ -1161,19 +891,17 @@ router.post('/pull', async (req, res) => {
console.log('No upstream configured, using origin/branch as fallback');
}
validateRemoteName(remoteName);
validateBranchName(remoteBranch);
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Pull completed successfully',
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Pull completed successfully',
remoteName,
remoteBranch
});
} catch (error) {
console.error('Git pull error:', error);
// Enhanced error handling for common pull scenarios
let errorMessage = 'Pull failed';
let details = error.message;
@@ -1215,12 +943,13 @@ router.post('/push', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const branch = await getCurrentBranchName(projectPath);
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
@@ -1229,13 +958,11 @@ router.post('/push', async (req, res) => {
console.log('No upstream configured, using origin/branch as fallback');
}
validateRemoteName(remoteName);
validateBranchName(remoteBranch);
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Push completed successfully',
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Push completed successfully',
remoteName,
remoteBranch
});
@@ -1285,38 +1012,35 @@ router.post('/publish', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Validate branch name
validateBranchName(branch);
// Get current branch to verify it matches the requested branch
const currentBranchName = await getCurrentBranchName(projectPath);
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const currentBranchName = currentBranch.trim();
if (currentBranchName !== branch) {
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
});
}
// Check if remote exists
let remoteName = 'origin';
try {
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length === 0) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
} catch (error) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
// Publish the branch (set upstream and push)
validateRemoteName(remoteName);
const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
res.json({
success: true,
@@ -1363,18 +1087,10 @@ router.post('/discard', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status to determine correct discard command
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'No changes to discard for this file' });
}
@@ -1383,7 +1099,7 @@ router.post('/discard', async (req, res) => {
if (status === '??') {
// Untracked file or directory - delete it
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
@@ -1393,13 +1109,13 @@ router.post('/discard', async (req, res) => {
}
} else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
await execAsync(`git restore "${file}"`, { cwd: projectPath });
} else if (status.includes('A')) {
// Added file - unstage it
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
}
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
res.json({ success: true, message: `Changes discarded for ${file}` });
} catch (error) {
console.error('Git discard error:', error);
res.status(500).json({ error: error.message });
@@ -1417,17 +1133,9 @@ router.post('/delete-untracked', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is actually untracked
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });
@@ -1440,16 +1148,16 @@ router.post('/delete-untracked', async (req, res) => {
}
// Delete the untracked file or directory
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// Use rm with recursive option for directories
await fs.rm(filePath, { recursive: true, force: true });
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
} else {
await fs.unlink(filePath);
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
}
} catch (error) {
console.error('Git delete untracked error:', error);

View File

@@ -1,303 +0,0 @@
import express from 'express';
import path from 'path';
import http from 'http';
import mime from 'mime-types';
import fs from 'fs';
import {
scanPlugins,
getPluginsConfig,
getPluginsDir,
savePluginsConfig,
getPluginDir,
resolvePluginAssetPath,
installPluginFromGit,
updatePluginFromGit,
uninstallPlugin,
} from '../utils/plugin-loader.js';
import {
startPluginServer,
stopPluginServer,
getPluginPort,
isPluginRunning,
} from '../utils/plugin-process-manager.js';
const router = express.Router();
// GET / — List all installed plugins (includes server running status)
router.get('/', (req, res) => {
try {
const plugins = scanPlugins().map(p => ({
...p,
serverRunning: p.server ? isPluginRunning(p.name) : false,
}));
res.json({ plugins });
} catch (err) {
res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
}
});
// GET /:name/manifest — Get single plugin manifest
router.get('/:name/manifest', (req, res) => {
try {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) {
return res.status(404).json({ error: 'Plugin not found' });
}
res.json(plugin);
} catch (err) {
res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
}
});
// GET /:name/assets/* — Serve plugin static files
router.get('/:name/assets/*', (req, res) => {
const pluginName = req.params.name;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const assetPath = req.params[0];
if (!assetPath) {
return res.status(400).json({ error: 'No asset path specified' });
}
const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
if (!resolvedPath) {
return res.status(404).json({ error: 'Asset not found' });
}
try {
const stat = fs.statSync(resolvedPath);
if (!stat.isFile()) {
return res.status(404).json({ error: 'Asset not found' });
}
} catch {
return res.status(404).json({ error: 'Asset not found' });
}
const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
const stream = fs.createReadStream(resolvedPath);
stream.on('error', () => {
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to read asset' });
} else {
res.end();
}
});
stream.pipe(res);
});
// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
router.put('/:name/enable', async (req, res) => {
try {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: '"enabled" must be a boolean' });
}
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) {
return res.status(404).json({ error: 'Plugin not found' });
}
const config = getPluginsConfig();
config[req.params.name] = { ...config[req.params.name], enabled };
savePluginsConfig(config);
// Start or stop the plugin server as needed
if (plugin.server) {
if (enabled && !isPluginRunning(plugin.name)) {
const pluginDir = getPluginDir(plugin.name);
if (pluginDir) {
try {
await startPluginServer(plugin.name, pluginDir, plugin.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
}
}
} else if (!enabled && isPluginRunning(plugin.name)) {
await stopPluginServer(plugin.name);
}
}
res.json({ success: true, name: req.params.name, enabled });
} catch (err) {
res.status(500).json({ error: 'Failed to update plugin', details: err.message });
}
});
// POST /install — Install plugin from git URL
router.post('/install', async (req, res) => {
try {
const { url } = req.body;
if (!url || typeof url !== 'string') {
return res.status(400).json({ error: '"url" is required and must be a string' });
}
// Basic URL validation
if (!url.startsWith('https://') && !url.startsWith('git@')) {
return res.status(400).json({ error: 'URL must start with https:// or git@' });
}
const manifest = await installPluginFromGit(url);
// Auto-start the server if the plugin has one (enabled by default)
if (manifest.server) {
const pluginDir = getPluginDir(manifest.name);
if (pluginDir) {
try {
await startPluginServer(manifest.name, pluginDir, manifest.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
}
}
}
res.json({ success: true, plugin: manifest });
} catch (err) {
res.status(400).json({ error: 'Failed to install plugin', details: err.message });
}
});
// POST /:name/update — Pull latest from git (restarts server if running)
router.post('/:name/update', async (req, res) => {
try {
const pluginName = req.params.name;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const wasRunning = isPluginRunning(pluginName);
if (wasRunning) {
await stopPluginServer(pluginName);
}
const manifest = await updatePluginFromGit(pluginName);
// Restart server if it was running before the update
if (wasRunning && manifest.server) {
const pluginDir = getPluginDir(pluginName);
if (pluginDir) {
try {
await startPluginServer(pluginName, pluginDir, manifest.server);
} catch (err) {
console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
}
}
}
res.json({ success: true, plugin: manifest });
} catch (err) {
res.status(400).json({ error: 'Failed to update plugin', details: err.message });
}
});
// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
router.all('/:name/rpc/*', async (req, res) => {
const pluginName = req.params.name;
const rpcPath = req.params[0] || '';
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
let port = getPluginPort(pluginName);
if (!port) {
// Lazily start the plugin server if it exists and is enabled
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === pluginName);
if (!plugin || !plugin.server) {
return res.status(503).json({ error: 'Plugin server is not running' });
}
if (!plugin.enabled) {
return res.status(503).json({ error: 'Plugin is disabled' });
}
const pluginDir = path.join(getPluginsDir(), plugin.dirName);
try {
port = await startPluginServer(pluginName, pluginDir, plugin.server);
} catch (err) {
return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
}
}
// Inject configured secrets as headers
const config = getPluginsConfig();
const pluginConfig = config[pluginName] || {};
const secrets = pluginConfig.secrets || {};
const headers = {
'content-type': req.headers['content-type'] || 'application/json',
};
// Add per-plugin secrets as X-Plugin-Secret-* headers
for (const [key, value] of Object.entries(secrets)) {
headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
}
// Reconstruct query string
const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
const options = {
hostname: '127.0.0.1',
port,
path: `/${rpcPath}${qs}`,
method: req.method,
headers,
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
if (!res.headersSent) {
res.status(502).json({ error: 'Plugin server error', details: err.message });
} else {
res.end();
}
});
// Forward body (already parsed by express JSON middleware, so re-stringify).
// Check content-length to detect whether a body was actually sent, since
// req.body can be falsy for valid payloads like 0, false, null, or {}.
const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
if (hasBody && req.body !== undefined) {
const bodyStr = JSON.stringify(req.body);
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
proxyReq.write(bodyStr);
}
proxyReq.end();
});
// DELETE /:name — Uninstall plugin (stops server first)
router.delete('/:name', async (req, res) => {
try {
const pluginName = req.params.name;
// Validate name format to prevent path traversal
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
// Stop server and wait for the process to fully exit before deleting files
if (isPluginRunning(pluginName)) {
await stopPluginServer(pluginName);
}
await uninstallPlugin(pluginName);
res.json({ success: true, name: pluginName });
} catch (err) {
res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
}
});
export default router;

View File

@@ -311,11 +311,13 @@ router.post('/create-workspace', async (req, res) => {
* Helper function to get GitHub token from database
*/
async function getGithubTokenById(tokenId, userId) {
const { db } = await import('../database/db.js');
const { getDatabase } = await import('../database/db.js');
const db = await getDatabase();
const credential = db.prepare(
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
).get(tokenId, userId, 'github_token');
const credential = await db.get(
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
[tokenId, userId, 'github_token']
);
// Return in the expected format (github_token field for compatibility)
if (credential) {

View File

@@ -1,5 +1,6 @@
import express from 'express';
import { apiKeysDb, credentialsDb } from '../database/db.js';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
import { getPublicKey } from '../services/vapid-keys.js';
const router = express.Router();
@@ -175,4 +176,70 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
}
});
// ===============================
// Notification Preferences
// ===============================
router.get('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
res.json({ success: true, preferences });
} catch (error) {
console.error('Error fetching notification preferences:', error);
res.status(500).json({ error: 'Failed to fetch notification preferences' });
}
});
router.put('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
res.json({ success: true, preferences });
} catch (error) {
console.error('Error saving notification preferences:', error);
res.status(500).json({ error: 'Failed to save notification preferences' });
}
});
// ===============================
// Push Subscription Management
// ===============================
router.get('/push/vapid-public-key', async (req, res) => {
try {
const publicKey = getPublicKey();
res.json({ publicKey });
} catch (error) {
console.error('Error fetching VAPID public key:', error);
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
}
});
router.post('/push/subscribe', async (req, res) => {
try {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Missing subscription fields' });
}
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
res.json({ success: true });
} catch (error) {
console.error('Error saving push subscription:', error);
res.status(500).json({ error: 'Failed to save push subscription' });
}
});
router.post('/push/unsubscribe', async (req, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Missing endpoint' });
}
pushSubscriptionsDb.removeSubscription(endpoint);
res.json({ success: true });
} catch (error) {
console.error('Error removing push subscription:', error);
res.status(500).json({ error: 'Failed to remove push subscription' });
}
});
export default router;

View File

@@ -2,29 +2,12 @@ import express from 'express';
import { userDb } from '../database/db.js';
import { authenticateToken } from '../middleware/auth.js';
import { getSystemGitConfig } from '../utils/gitConfig.js';
import { spawn } from 'child_process';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const router = express.Router();
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { ...options, shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.stderr.on('data', (data) => { stderr += data.toString(); });
child.on('error', (error) => { reject(error); });
child.on('close', (code) => {
if (code === 0) { resolve({ stdout, stderr }); return; }
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
});
});
}
router.get('/git-config', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
@@ -72,8 +55,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
userDb.updateGitConfig(userId, gitName, gitEmail);
try {
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
} catch (gitError) {
console.error('Error applying git config:', gitError);

View File

@@ -0,0 +1,149 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function shouldSendPush(preferences, event) {
const webPushEnabled = Boolean(preferences?.channels?.webPush);
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return webPushEnabled && eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function buildPushBody(event) {
const CODE_MAP = {
'permission.required': {
title: 'Action Required',
body: event.meta?.toolName
? `Tool "${event.meta.toolName}" needs approval`
: 'A tool needs your approval'
},
'run.stopped': {
title: 'Run Stopped',
body: event.meta?.stopReason || 'The run has stopped'
},
'run.failed': {
title: 'Run Failed',
body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error'
},
'agent.notification': {
title: 'Agent Notification',
body: event.meta?.message ? String(event.meta.message) : 'You have a new notification'
}
};
const mapped = CODE_MAP[event.code];
return {
title: mapped?.title || 'Claude Code UI',
body: mapped?.body || 'You have a new notification',
data: {
sessionId: event.sessionId || null,
code: event.code
}
};
}
async function sendWebPush(userId, event) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return;
const payload = JSON.stringify(buildPushBody(event));
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
payload
)
)
);
// Clean up gone subscriptions (410 Gone or 404)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
}
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, event)) {
return;
}
if (isDuplicate(event)) {
return;
}
sendWebPush(userId, event).catch((err) => {
console.error('Web push send error:', err);
});
}
export {
createNotificationEvent,
notifyUserIfEnabled
};

View File

@@ -0,0 +1,35 @@
import webPush from 'web-push';
import { db } from '../database/db.js';
let cachedKeys = null;
function ensureVapidKeys() {
if (cachedKeys) return cachedKeys;
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
if (row) {
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
return cachedKeys;
}
const keys = webPush.generateVAPIDKeys();
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
cachedKeys = keys;
return cachedKeys;
}
function getPublicKey() {
return ensureVapidKeys().publicKey;
}
function configureWebPush() {
const keys = ensureVapidKeys();
webPush.setVapidDetails(
'mailto:noreply@claudecodeui.local',
keys.publicKey,
keys.privateKey
);
console.log('Web Push notifications configured');
}
export { ensureVapidKeys, getPublicKey, configureWebPush };

View File

@@ -1,9 +1,9 @@
import matter from 'gray-matter';
import { promises as fs } from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { parse as parseShellCommand } from 'shell-quote';
import { parseFrontmatter } from './frontmatter.js';
const execFileAsync = promisify(execFile);
@@ -32,7 +32,7 @@ const BASH_COMMAND_ALLOWLIST = [
*/
export function parseCommand(content) {
try {
const parsed = parseFrontmatter(content);
const parsed = matter(content);
return {
data: parsed.data || {},
content: parsed.content || '',

View File

@@ -1,18 +0,0 @@
import matter from 'gray-matter';
const disabledFrontmatterEngine = () => ({});
const frontmatterOptions = {
language: 'yaml',
// Disable JS/JSON frontmatter parsing to avoid executable project content.
// Mirrors Gatsby's mitigation for gray-matter.
engines: {
js: disabledFrontmatterEngine,
javascript: disabledFrontmatterEngine,
json: disabledFrontmatterEngine
}
};
export function parseFrontmatter(content) {
return matter(content, frontmatterOptions);
}

View File

@@ -1,17 +1,7 @@
import { spawn } from 'child_process';
import { exec } from 'child_process';
import { promisify } from 'util';
function spawnAsync(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { shell: false });
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('error', (error) => { reject(error); });
child.on('close', (code) => {
if (code === 0) { resolve({ stdout }); return; }
reject(new Error(`Command failed with code ${code}`));
});
});
}
const execAsync = promisify(exec);
/**
* Read git configuration from system's global git config
@@ -20,8 +10,8 @@ function spawnAsync(command, args) {
export async function getSystemGitConfig() {
try {
const [nameResult, emailResult] = await Promise.all([
spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
]);
return {

View File

@@ -1,408 +0,0 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { spawn } from 'child_process';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
/** Strip embedded credentials from a repo URL before exposing it to the client. */
function sanitizeRepoUrl(raw) {
try {
const u = new URL(raw);
u.username = '';
u.password = '';
return u.toString().replace(/\/$/, '');
} catch {
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
return raw.replace(/\/\/[^@/]+@/, '//');
}
}
const ALLOWED_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab'];
export function getPluginsDir() {
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
return PLUGINS_DIR;
}
export function getPluginsConfig() {
try {
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
}
} catch {
// Corrupted config, start fresh
}
return {};
}
export function savePluginsConfig(config) {
const dir = path.dirname(PLUGINS_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
}
export function validateManifest(manifest) {
if (!manifest || typeof manifest !== 'object') {
return { valid: false, error: 'Manifest must be a JSON object' };
}
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!manifest[field] || typeof manifest[field] !== 'string') {
return { valid: false, error: `Missing or invalid required field: ${field}` };
}
}
// Sanitize name — only allow alphanumeric, hyphens, underscores
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
}
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
}
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
}
// Validate entry is a relative path without traversal
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
return { valid: false, error: 'Entry must be a relative path without ".."' };
}
if (manifest.server !== undefined && manifest.server !== null) {
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
}
}
if (manifest.permissions !== undefined) {
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
return { valid: false, error: 'Permissions must be an array of strings' };
}
}
return { valid: true };
}
export function scanPlugins() {
const pluginsDir = getPluginsDir();
const config = getPluginsConfig();
const plugins = [];
let entries;
try {
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
} catch {
return plugins;
}
const seenNames = new Set();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip transient temp directories from in-progress installs
if (entry.name.startsWith('.tmp-')) continue;
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const validation = validateManifest(manifest);
if (!validation.valid) {
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
continue;
}
// Skip duplicate manifest names
if (seenNames.has(manifest.name)) {
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
continue;
}
seenNames.add(manifest.name);
// Try to read git remote URL
let repoUrl = null;
try {
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
if (fs.existsSync(gitConfigPath)) {
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
const match = gitConfig.match(/url\s*=\s*(.+)/);
if (match) {
repoUrl = match[1].trim().replace(/\.git$/, '');
// Convert SSH URLs to HTTPS
if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
}
// Strip embedded credentials (e.g. https://user:pass@host/...)
repoUrl = sanitizeRepoUrl(repoUrl);
}
}
} catch { /* ignore */ }
plugins.push({
name: manifest.name,
displayName: manifest.displayName,
version: manifest.version || '0.0.0',
description: manifest.description || '',
author: manifest.author || '',
icon: manifest.icon || 'Puzzle',
type: manifest.type || 'module',
slot: manifest.slot || 'tab',
entry: manifest.entry,
server: manifest.server || null,
permissions: manifest.permissions || [],
enabled: config[manifest.name]?.enabled !== false, // enabled by default
dirName: entry.name,
repoUrl,
});
} catch (err) {
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
}
}
return plugins;
}
export function getPluginDir(name) {
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === name);
if (!plugin) return null;
return path.join(getPluginsDir(), plugin.dirName);
}
export function resolvePluginAssetPath(name, assetPath) {
const pluginDir = getPluginDir(name);
if (!pluginDir) return null;
const resolved = path.resolve(pluginDir, assetPath);
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
if (!fs.existsSync(resolved)) return null;
const realResolved = fs.realpathSync(resolved);
const realPluginDir = fs.realpathSync(pluginDir);
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
return null;
}
return realResolved;
}
export function installPluginFromGit(url) {
return new Promise((resolve, reject) => {
if (typeof url !== 'string' || !url.trim()) {
return reject(new Error('Invalid URL: must be a non-empty string'));
}
if (url.startsWith('-')) {
return reject(new Error('Invalid URL: must not start with "-"'));
}
// Extract repo name from URL for directory name
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
const repoName = urlClean.split('/').pop();
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
return reject(new Error('Could not determine a valid directory name from the URL'));
}
const pluginsDir = getPluginsDir();
const targetDir = path.resolve(pluginsDir, repoName);
// Ensure the resolved target directory stays within the plugins directory
if (!targetDir.startsWith(pluginsDir + path.sep)) {
return reject(new Error('Invalid plugin directory path'));
}
if (fs.existsSync(targetDir)) {
return reject(new Error(`Plugin directory "${repoName}" already exists`));
}
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
const cleanupTemp = () => {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
};
const finalize = (manifest) => {
try {
fs.renameSync(tempDir, targetDir);
} catch (err) {
cleanupTemp();
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
}
resolve(manifest);
};
const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
gitProcess.on('close', (code) => {
if (code !== 0) {
cleanupTemp();
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
}
// Validate manifest exists
const manifestPath = path.join(tempDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
cleanupTemp();
return reject(new Error('Cloned repository does not contain a manifest.json'));
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
cleanupTemp();
return reject(new Error('manifest.json is not valid JSON'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
cleanupTemp();
return reject(new Error(`Invalid manifest: ${validation.error}`));
}
// Reject if another installed plugin already uses this name
const existing = scanPlugins().find(p => p.name === manifest.name);
if (existing) {
cleanupTemp();
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
}
// Run npm install if package.json exists.
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
const packageJsonPath = path.join(tempDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: tempDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
cleanupTemp();
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
}
finalize(manifest);
});
npmProcess.on('error', (err) => {
cleanupTemp();
reject(err);
});
} else {
finalize(manifest);
}
});
gitProcess.on('error', (err) => {
cleanupTemp();
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
}
export function updatePluginFromGit(name) {
return new Promise((resolve, reject) => {
const pluginDir = getPluginDir(name);
if (!pluginDir) {
return reject(new Error(`Plugin "${name}" not found`));
}
// Only fast-forward to avoid silent divergence
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
gitProcess.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
}
// Re-validate manifest after update
const manifestPath = path.join(pluginDir, 'manifest.json');
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return reject(new Error('manifest.json is not valid JSON after update'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
}
// Re-run npm install if package.json exists
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
}
resolve(manifest);
});
npmProcess.on('error', (err) => reject(err));
} else {
resolve(manifest);
}
});
gitProcess.on('error', (err) => {
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
}
export async function uninstallPlugin(name) {
const pluginDir = getPluginDir(name);
if (!pluginDir) {
throw new Error(`Plugin "${name}" not found`);
}
// On Windows, file handles may be released slightly after process exit.
// Retry a few times with a short delay before giving up.
const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 500;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
fs.rmSync(pluginDir, { recursive: true, force: true });
break;
} catch (err) {
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
} else {
throw err;
}
}
}
// Remove from config
const config = getPluginsConfig();
delete config[name];
savePluginsConfig(config);
}

View File

@@ -1,184 +0,0 @@
import { spawn } from 'child_process';
import path from 'path';
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
// Map<pluginName, { process, port }>
const runningPlugins = new Map();
// Map<pluginName, Promise<port>> — in-flight start operations
const startingPlugins = new Map();
/**
* Start a plugin's server subprocess.
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
* to stdout within 10 seconds.
*/
export function startPluginServer(name, pluginDir, serverEntry) {
if (runningPlugins.has(name)) {
return Promise.resolve(runningPlugins.get(name).port);
}
// Coalesce concurrent starts for the same plugin
if (startingPlugins.has(name)) {
return startingPlugins.get(name);
}
const startPromise = new Promise((resolve, reject) => {
const serverPath = path.join(pluginDir, serverEntry);
// Restricted env — only essentials, no host secrets
const pluginProcess = spawn('node', [serverPath], {
cwd: pluginDir,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let resolved = false;
let stdout = '';
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
pluginProcess.kill();
reject(new Error('Plugin server did not report ready within 10 seconds'));
}
}, 10000);
pluginProcess.stdout.on('data', (data) => {
if (resolved) return;
stdout += data.toString();
// Look for the JSON ready line
const lines = stdout.split('\n');
for (const line of lines) {
try {
const msg = JSON.parse(line.trim());
if (msg.ready && typeof msg.port === 'number') {
clearTimeout(timeout);
resolved = true;
runningPlugins.set(name, { process: pluginProcess, port: msg.port });
pluginProcess.on('exit', () => {
runningPlugins.delete(name);
});
console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
resolve(msg.port);
}
} catch {
// Not JSON yet, keep buffering
}
}
});
pluginProcess.stderr.on('data', (data) => {
console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
});
pluginProcess.on('error', (err) => {
clearTimeout(timeout);
if (!resolved) {
resolved = true;
reject(new Error(`Failed to start plugin server: ${err.message}`));
}
});
pluginProcess.on('exit', (code) => {
clearTimeout(timeout);
runningPlugins.delete(name);
if (!resolved) {
resolved = true;
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
}
});
}).finally(() => {
startingPlugins.delete(name);
});
startingPlugins.set(name, startPromise);
return startPromise;
}
/**
* Stop a plugin's server subprocess.
* Returns a Promise that resolves when the process has fully exited.
*/
export function stopPluginServer(name) {
const entry = runningPlugins.get(name);
if (!entry) return Promise.resolve();
return new Promise((resolve) => {
const cleanup = () => {
clearTimeout(forceKillTimer);
runningPlugins.delete(name);
resolve();
};
entry.process.once('exit', cleanup);
entry.process.kill('SIGTERM');
// Force kill after 5 seconds if still running
const forceKillTimer = setTimeout(() => {
if (runningPlugins.has(name)) {
entry.process.kill('SIGKILL');
cleanup();
}
}, 5000);
console.log(`[Plugins] Server stopped for "${name}"`);
});
}
/**
* Get the port a running plugin server is listening on.
*/
export function getPluginPort(name) {
return runningPlugins.get(name)?.port ?? null;
}
/**
* Check if a plugin's server is running.
*/
export function isPluginRunning(name) {
return runningPlugins.has(name);
}
/**
* Stop all running plugin servers (called on host shutdown).
*/
export function stopAllPlugins() {
const stops = [];
for (const [name] of runningPlugins) {
stops.push(stopPluginServer(name));
}
return Promise.all(stops);
}
/**
* Start servers for all enabled plugins that have a server entry.
* Called once on host server boot.
*/
export async function startEnabledPluginServers() {
const plugins = scanPlugins();
const config = getPluginsConfig();
for (const plugin of plugins) {
if (!plugin.server) continue;
if (config[plugin.name]?.enabled === false) continue;
const pluginDir = getPluginDir(plugin.name);
if (!pluginDir) continue;
try {
await startPluginServer(plugin.name, pluginDir, plugin.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
}
}
}

View File

@@ -13,14 +13,14 @@
export const CLAUDE_MODELS = {
// Models in SDK format (what the actual SDK accepts)
OPTIONS: [
{ value: "sonnet", label: "Sonnet" },
{ value: "opus", label: "Opus" },
{ value: "haiku", label: "Haiku" },
{ value: "opusplan", label: "Opus Plan" },
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'opus', label: 'Opus' },
{ value: 'haiku', label: 'Haiku' },
{ value: 'opusplan', label: 'Opus Plan' },
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
],
DEFAULT: "sonnet",
DEFAULT: 'sonnet'
};
/**
@@ -28,28 +28,26 @@ export const CLAUDE_MODELS = {
*/
export const CURSOR_MODELS = {
OPTIONS: [
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "gpt-5.1", label: "GPT-5.1" },
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
{ value: "composer-1", label: "Composer 1" },
{ value: "auto", label: "Auto" },
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
{ value: "grok", label: "Grok" },
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
{ value: 'composer-1', label: 'Composer 1' },
{ value: 'auto', label: 'Auto' },
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
{ value: 'grok', label: 'Grok' }
],
DEFAULT: "gpt-5-3-codex",
DEFAULT: 'gpt-5'
};
/**
@@ -57,16 +55,15 @@ export const CURSOR_MODELS = {
*/
export const CODEX_MODELS = {
OPTIONS: [
{ value: "gpt-5.4", label: "GPT-5.4" },
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ value: "o3", label: "O3" },
{ value: "o4-mini", label: "O4-mini" },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'o3', label: 'O3' },
{ value: 'o4-mini', label: 'O4-mini' }
],
DEFAULT: "gpt-5.4",
DEFAULT: 'gpt-5.3-codex'
};
/**
@@ -74,19 +71,16 @@ export const CODEX_MODELS = {
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
{
value: "gemini-2.0-flash-thinking-exp",
label: "Gemini 2.0 Flash Thinking",
},
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
],
DEFAULT: "gemini-2.5-flash",
DEFAULT: 'gemini-2.5-flash'
};

View File

@@ -1,11 +1,11 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, ProtectedRoute } from './components/auth';
import { AuthProvider } from './contexts/AuthContext';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
import { PluginsProvider } from './contexts/PluginsContext';
import ProtectedRoute from './components/ProtectedRoute';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -15,9 +15,8 @@ export default function App() {
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<PluginsProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
@@ -26,9 +25,8 @@ export default function App() {
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</PluginsProvider>
</TaskMasterProvider>
</TasksSettingsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { X, Sparkles } from 'lucide-react';
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create AI-Generated Task</h3>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* AI-First Approach */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
💡 Pro Tip: Ask Claude Code Directly!
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
You can simply ask Claude Code in the chat to create tasks for you.
The AI assistant will automatically generate detailed tasks with research-backed insights.
</p>
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3 mb-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example:</p>
<p className="text-sm text-gray-900 dark:text-white font-mono">
"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach."
</p>
</div>
<p className="text-xs text-blue-700 dark:text-blue-300">
<strong>This runs:</strong> <code className="bg-blue-100 dark:bg-blue-900/50 px-1 rounded text-xs">
task-master add-task --prompt="Implement user profile image uploads using Cloudinary" --research
</code>
</p>
</div>
</div>
</div>
{/* Learn More Link */}
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
For more examples and advanced usage patterns:
</p>
<a
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-medium"
>
View TaskMaster Documentation
</a>
</div>
{/* Footer */}
<div className="pt-4">
<button
onClick={onClose}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Got it, I'll ask Claude Code directly
</button>
</div>
</div>
</div>
</div>
);
};
export default CreateTaskModal;

View File

@@ -0,0 +1,48 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
type DarkModeToggleProps = {
checked?: boolean;
onToggle?: (nextValue: boolean) => void;
ariaLabel?: string;
};
function DarkModeToggle({ checked, onToggle, ariaLabel = 'Toggle dark mode' }: DarkModeToggleProps) {
const { isDarkMode, toggleDarkMode } = useTheme();
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
const isEnabled = isControlled ? checked : isDarkMode;
const handleToggle = () => {
if (isControlled) {
onToggle(!isEnabled);
return;
}
toggleDarkMode();
};
return (
<button
onClick={handleToggle}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isEnabled}
aria-label={ariaLabel}
>
<span className="sr-only">{ariaLabel}</span>
<span
className={`${
isEnabled ? 'translate-x-7' : 'translate-x-1'
} h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isEnabled ? (
<Moon className="h-3.5 w-3.5 text-gray-700" />
) : (
<Sun className="h-3.5 w-3.5 text-yellow-500" />
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -0,0 +1,41 @@
import React from 'react';
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
if (!diff) {
return (
<div className="p-4 text-center text-muted-foreground text-sm">
No diff available
</div>
);
}
const renderDiffLine = (line, index) => {
const isAddition = line.startsWith('+') && !line.startsWith('+++');
const isDeletion = line.startsWith('-') && !line.startsWith('---');
const isHeader = line.startsWith('@@');
return (
<div
key={index}
className={`font-mono text-xs px-3 py-0.5 ${
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
} ${
isAddition ? 'bg-green-50 dark:bg-green-950/50 text-green-700 dark:text-green-300' :
isDeletion ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' :
isHeader ? 'bg-primary/5 text-primary' :
'text-muted-foreground/70'
}`}
>
{line}
</div>
);
};
return (
<div className="diff-viewer">
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
</div>
);
}
export default DiffViewer;

View File

@@ -0,0 +1,77 @@
import React, { useCallback, useState } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{showDetails && error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{error.toString()}
{componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={resetErrorBoundary}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
const [componentStack, setComponentStack] = useState(null);
const handleError = useCallback((error, errorInfo) => {
console.error('ErrorBoundary caught an error:', error, errorInfo);
setComponentStack(errorInfo?.componentStack || null);
}, []);
const handleReset = useCallback(() => {
setComponentStack(null);
onRetry?.();
}, [onRetry]);
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
<ErrorFallback
error={error}
resetErrorBoundary={resetErrorBoundary}
showDetails={showDetails}
componentStack={componentStack}
/>
), [showDetails, componentStack]);
return (
<ReactErrorBoundary
fallbackRender={renderFallback}
onError={handleError}
onReset={handleReset}
resetKeys={resetKeys}
>
{children}
</ReactErrorBoundary>
);
}
export default ErrorBoundary;

View File

@@ -1,3 +1,5 @@
import React from 'react';
const GeminiLogo = ({className = 'w-5 h-5'}) => {
return (
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function GeminiStatus({ status, onAbort, isLoading }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
}, 1000);
return () => clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(timer);
}, [isLoading]);
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
return (
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
<div className="flex-1">
<div className="flex items-center gap-3">
{/* Animated spinner */}
<span className={cn(
"text-xl transition-all duration-500",
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
)}>
{currentSpinner}
</span>
{/* Status text - first line */}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{statusText}...</span>
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="hidden sm:inline">Stop</span>
</button>
)}
</div>
</div>
);
}
export default GeminiStatus;

View File

@@ -0,0 +1,74 @@
/**
* Language Selector Component
*
* A dropdown component for selecting the application language.
* Automatically updates the i18n language and persists to localStorage.
*
* Props:
* @param {boolean} compact - If true, uses compact style (default: false)
*/
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { languages } from '../i18n/languages';
function LanguageSelector({ compact = false }) {
const { i18n, t } = useTranslation('settings');
const handleLanguageChange = (event) => {
const newLanguage = event.target.value;
i18n.changeLanguage(newLanguage);
};
// Compact style for QuickSettingsPanel
if (compact) {
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('account.language')}
</span>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="w-[100px] text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.nativeName}
</option>
))}
</select>
</div>
);
}
// Full style for Settings page
return (
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{t('account.languageLabel')}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{t('account.languageDescription')}
</div>
</div>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-36"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.nativeName}
</option>
))}
</select>
</div>
</div>
);
}
export default LanguageSelector;

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
const { t } = useTranslation('auth');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError(t('errors.requiredFields'));
return;
}
setIsLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">{t('login.title')}</h1>
<p className="text-muted-foreground mt-2">
{t('login.description')}
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
{t('login.username')}
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('login.placeholders.username')}
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
{t('login.password')}
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('login.placeholders.password')}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? t('login.loading') : t('login.submit')}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter your credentials to access Claude Code UI
</p>
</div>
</div>
</div>
</div>
);
};
export default LoginForm;

View File

@@ -0,0 +1,153 @@
import { X, ExternalLink, KeyRound } from 'lucide-react';
import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication
*
* @param {Object} props
* @param {boolean} props.isOpen - Whether the modal is visible
* @param {Function} props.onClose - Callback when modal is closed
* @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/
function LoginModal({
isOpen,
onClose,
provider = 'claude',
project,
onComplete,
customCommand,
isAuthenticated = false,
isOnboarding = false
}) {
if (!isOpen) return null;
const getCommand = () => {
if (customCommand) return customCommand;
switch (provider) {
case 'claude':
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
case 'gemini':
// No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json`
return 'gemini status';
default:
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
}
};
const getTitle = () => {
switch (provider) {
case 'claude':
return 'Claude CLI Login';
case 'cursor':
return 'Cursor CLI Login';
case 'codex':
return 'Codex CLI Login';
case 'gemini':
return 'Gemini CLI Configuration';
default:
return 'CLI Login';
}
};
const handleComplete = (exitCode) => {
if (onComplete) {
onComplete(exitCode);
}
// Keep modal open so users can read login output and close explicitly.
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{getTitle()}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close login modal"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{provider === 'gemini' ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
Setup Gemini API Access
</h4>
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
</p>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
<ol className="space-y-4">
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
>
Google AI Studio <ExternalLink className="w-3 h-3" />
</a>
</div>
</li>
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
gemini config set api_key YOUR_KEY
</code>
</div>
</li>
</ol>
</div>
<button
onClick={onClose}
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Done
</button>
</div>
) : (
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
)}
</div>
</div>
</div>
);
}
export default LoginModal;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTaskMaster } from '../contexts/TaskMasterContext';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const navItems = [
{
id: 'chat',
icon: MessageSquare,
label: 'Chat',
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
label: 'Shell',
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
label: 'Files',
onClick: () => setActiveTab('files')
},
{
id: 'git',
icon: GitBranch,
label: 'Git',
onClick: () => setActiveTab('git')
},
...(shouldShowTasksTab ? [{
id: 'tasks',
icon: ClipboardCheck,
label: 'Tasks',
onClick: () => setActiveTab('tasks')
}] : [])
];
return (
<div
className={`fixed bottom-0 left-0 right-0 z-50 px-3 pb-[max(8px,env(safe-area-inset-bottom))] transform transition-transform duration-300 ease-in-out ${
isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`}
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
}}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
>
{isActive && (
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
)}
<Icon
className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}
export default MobileNav;

View File

@@ -0,0 +1,695 @@
import React, { useState } from 'react';
import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause, ChevronDown, ChevronUp, Plus, FileText, Settings, X, Terminal, Eye, Play, Zap, Target } from 'lucide-react';
import { cn } from '../lib/utils';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { api } from '../utils/api';
import Shell from './shell/view/Shell';
import TaskDetail from './TaskDetail';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
const { nextTask, tasks, currentProject, isLoadingTasks, projectTaskMaster, refreshTasks, refreshProjects } = useTaskMaster();
const [showDetails, setShowDetails] = useState(false);
const [showTaskOptions, setShowTaskOptions] = useState(false);
const [showCreateTaskModal, setShowCreateTaskModal] = useState(false);
const [showTemplateSelector, setShowTemplateSelector] = useState(false);
const [showCLI, setShowCLI] = useState(false);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Handler functions
const handleInitializeTaskMaster = async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const response = await api.taskmaster.init(currentProject.name);
if (response.ok) {
await refreshProjects();
setShowTaskOptions(false);
} else {
const error = await response.json();
console.error('Failed to initialize TaskMaster:', error);
alert(`Failed to initialize TaskMaster: ${error.message}`);
}
} catch (error) {
console.error('Error initializing TaskMaster:', error);
alert('Error initializing TaskMaster. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleCreateManualTask = () => {
setShowCreateTaskModal(true);
setShowTaskOptions(false);
};
const handleParsePRD = () => {
setShowTemplateSelector(true);
setShowTaskOptions(false);
};
// Don't show if no project or still loading
if (!currentProject || isLoadingTasks) {
return null;
}
let bannerContent;
// Show setup message only if no tasks exist AND TaskMaster is not configured
if ((!tasks || tasks.length === 0) && !projectTaskMaster?.hasTaskmaster) {
bannerContent = (
<div className={cn(
'bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4',
className
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<List className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
TaskMaster AI is not configured
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowTaskOptions(!showTaskOptions)}
className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center gap-1"
>
<Settings className="w-3 h-3" />
Initialize TaskMaster AI
</button>
</div>
</div>
{showTaskOptions && (
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
{!projectTaskMaster?.hasTaskmaster && (
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/50 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
🎯 What is TaskMaster?
</h4>
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<p> <strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks</p>
<p> <strong>PRD Templates:</strong> Generate tasks from Product Requirements Documents</p>
<p> <strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
<p> <strong>Progress Visualization:</strong> Kanban boards and detailed task analytics</p>
<p> <strong>CLI Integration:</strong> Use taskmaster commands for advanced workflows</p>
</div>
</div>
)}
<div className="flex flex-col gap-2">
{!projectTaskMaster?.hasTaskmaster ? (
<button
className="text-xs px-3 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 rounded transition-colors text-left flex items-center gap-2"
onClick={() => setShowCLI(true)}
>
<Terminal className="w-3 h-3" />
Initialize TaskMaster
</button>
) : (
<>
<div className="mb-2 p-2 bg-green-50 dark:bg-green-900/30 rounded text-xs text-green-800 dark:text-green-200">
<strong>Add more tasks:</strong> Create additional tasks manually or generate them from a PRD template
</div>
<button
className="text-xs px-3 py-2 bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
onClick={handleCreateManualTask}
disabled={isLoading}
>
<Plus className="w-3 h-3" />
Create a new task manually
</button>
<button
className="text-xs px-3 py-2 bg-purple-100 dark:bg-purple-900 hover:bg-purple-200 dark:hover:bg-purple-800 text-purple-800 dark:text-purple-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
onClick={handleParsePRD}
disabled={isLoading}
>
<FileText className="w-3 h-3" />
{isLoading ? 'Parsing...' : 'Generate tasks from PRD template'}
</button>
</>
)}
</div>
</div>
)}
</div>
);
} else if (nextTask) {
// Show next task if available
bannerContent = (
<div className={cn(
'bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4',
className
)}>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center flex-shrink-0">
<Target className="w-3 h-3 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Task {nextTask.id}</span>
{nextTask.priority === 'high' && (
<div className="w-4 h-4 rounded bg-red-100 dark:bg-red-900/50 flex items-center justify-center" title="High Priority">
<Zap className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</div>
)}
{nextTask.priority === 'medium' && (
<div className="w-4 h-4 rounded bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center" title="Medium Priority">
<Flag className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
</div>
)}
{nextTask.priority === 'low' && (
<div className="w-4 h-4 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center" title="Low Priority">
<Circle className="w-2.5 h-2.5 text-gray-400 dark:text-gray-500" />
</div>
)}
</div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 line-clamp-1">
{nextTask.title}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onStartTask?.()}
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium transition-colors shadow-sm flex items-center gap-1"
>
<Play className="w-3 h-3" />
Start Task
</button>
<button
onClick={() => setShowTaskDetail(true)}
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
title="View task details"
>
<Eye className="w-3 h-3" />
</button>
{onShowAllTasks && (
<button
onClick={onShowAllTasks}
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
title="View all tasks"
>
<List className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
} else if (tasks && tasks.length > 0) {
// Show completion message only if there are tasks and all are done
const completedTasks = tasks.filter(task => task.status === 'done').length;
const totalTasks = tasks.length;
bannerContent = (
<div className={cn(
'bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4',
className
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
{completedTasks === totalTasks ? "All done! 🎉" : "No pending tasks"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400">
{completedTasks}/{totalTasks}
</span>
<button
onClick={onShowAllTasks}
className="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
>
Review
</button>
</div>
</div>
</div>
);
} else {
// TaskMaster is configured but no tasks exist - don't show anything in chat
bannerContent = null;
}
return (
<>
{bannerContent}
{/* Create Task Modal */}
{showCreateTaskModal && (
<CreateTaskModal
currentProject={currentProject}
onClose={() => setShowCreateTaskModal(false)}
onTaskCreated={() => {
refreshTasks();
setShowCreateTaskModal(false);
}}
/>
)}
{/* Template Selector Modal */}
{showTemplateSelector && (
<TemplateSelector
currentProject={currentProject}
onClose={() => setShowTemplateSelector(false)}
onTemplateApplied={() => {
refreshTasks();
setShowTemplateSelector(false);
}}
/>
)}
{/* TaskMaster CLI Setup Modal */}
{showCLI && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl h-[600px] flex flex-col">
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">TaskMaster Setup</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Interactive CLI for {currentProject?.displayName}</p>
</div>
</div>
<button
onClick={() => setShowCLI(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Terminal Container */}
<div className="flex-1 p-4">
<div className="h-full bg-black rounded-lg overflow-hidden">
<Shell
selectedProject={currentProject}
selectedSession={null}
isActive={true}
initialCommand="npx task-master init"
isPlainShell={true}
/>
</div>
</div>
{/* Modal Footer */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
TaskMaster initialization will start automatically
</div>
<button
onClick={() => setShowCLI(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
{/* Task Detail Modal */}
{showTaskDetail && nextTask && (
<TaskDetail
task={nextTask}
isOpen={showTaskDetail}
onClose={() => setShowTaskDetail(false)}
onStatusChange={() => refreshTasks?.()}
onTaskClick={null} // Disable dependency navigation in NextTaskBanner for now
/>
)}
</>
);
};
// Simple Create Task Modal Component
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium',
useAI: false,
prompt: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!currentProject) return;
setIsSubmitting(true);
try {
const taskData = formData.useAI
? { prompt: formData.prompt, priority: formData.priority }
: { title: formData.title, description: formData.description, priority: formData.priority };
const response = await api.taskmaster.addTask(currentProject.name, taskData);
if (response.ok) {
onTaskCreated();
} else {
const error = await response.json();
console.error('Failed to create task:', error);
alert(`Failed to create task: ${error.message}`);
}
} catch (error) {
console.error('Error creating task:', error);
alert('Error creating task. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Task</h3>
<button
onClick={onClose}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<input
type="checkbox"
checked={formData.useAI}
onChange={(e) => setFormData(prev => ({ ...prev, useAI: e.target.checked }))}
/>
Use AI to generate task details
</label>
</div>
{formData.useAI ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Task Description (AI will generate details)
</label>
<textarea
value={formData.prompt}
onChange={(e) => setFormData(prev => ({ ...prev, prompt: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
rows="3"
placeholder="Describe what you want to accomplish..."
required
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Task Title
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Enter task title..."
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
rows="3"
placeholder="Describe the task..."
required
/>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority
</label>
<select
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
disabled={isSubmitting || (formData.useAI && !formData.prompt.trim()) || (!formData.useAI && (!formData.title.trim() || !formData.description.trim()))}
>
{isSubmitting ? 'Creating...' : 'Create Task'}
</button>
</div>
</form>
</div>
</div>
);
};
// Template Selector Modal Component
const TemplateSelector = ({ currentProject, onClose, onTemplateApplied }) => {
const [templates, setTemplates] = useState([]);
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [customizations, setCustomizations] = useState({});
const [fileName, setFileName] = useState('prd.txt');
const [isLoading, setIsLoading] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [step, setStep] = useState('select'); // 'select', 'customize', 'generate'
useEffect(() => {
const loadTemplates = async () => {
try {
const response = await api.taskmaster.getTemplates();
if (response.ok) {
const data = await response.json();
setTemplates(data.templates);
}
} catch (error) {
console.error('Error loading templates:', error);
} finally {
setIsLoading(false);
}
};
loadTemplates();
}, []);
const handleSelectTemplate = (template) => {
setSelectedTemplate(template);
// Find placeholders in template content
const placeholders = template.content.match(/\[([^\]]+)\]/g) || [];
const uniquePlaceholders = [...new Set(placeholders.map(p => p.slice(1, -1)))];
const initialCustomizations = {};
uniquePlaceholders.forEach(placeholder => {
initialCustomizations[placeholder] = '';
});
setCustomizations(initialCustomizations);
setStep('customize');
};
const handleApplyTemplate = async () => {
if (!selectedTemplate || !currentProject) return;
setIsApplying(true);
try {
// Apply template
const applyResponse = await api.taskmaster.applyTemplate(currentProject.name, {
templateId: selectedTemplate.id,
fileName,
customizations
});
if (!applyResponse.ok) {
const error = await applyResponse.json();
throw new Error(error.message || 'Failed to apply template');
}
// Parse PRD to generate tasks
const parseResponse = await api.taskmaster.parsePRD(currentProject.name, {
fileName,
numTasks: 10
});
if (!parseResponse.ok) {
const error = await parseResponse.json();
throw new Error(error.message || 'Failed to generate tasks');
}
setStep('generate');
setTimeout(() => {
onTemplateApplied();
}, 2000);
} catch (error) {
console.error('Error applying template:', error);
alert(`Error: ${error.message}`);
setIsApplying(false);
}
};
if (isLoading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading templates...</span>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{step === 'select' ? 'Select PRD Template' :
step === 'customize' ? 'Customize Template' :
'Generating Tasks'}
</h3>
<button
onClick={onClose}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
{step === 'select' && (
<div className="space-y-3">
{templates.map((template) => (
<div
key={template.id}
className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
onClick={() => handleSelectTemplate(template)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white">{template.name}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{template.description}</p>
<span className="inline-block text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded mt-2">
{template.category}
</span>
</div>
<ArrowRight className="w-4 h-4 text-gray-400 mt-1" />
</div>
</div>
))}
</div>
)}
{step === 'customize' && selectedTemplate && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
File Name
</label>
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="prd.txt"
/>
</div>
{Object.keys(customizations).length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Customize Template
</label>
<div className="space-y-3">
{Object.entries(customizations).map(([key, value]) => (
<div key={key}>
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
</label>
<input
type="text"
value={value}
onChange={(e) => setCustomizations(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder={`Enter ${key.toLowerCase()}`}
/>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<button
onClick={() => setStep('select')}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Back
</button>
<button
onClick={handleApplyTemplate}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded disabled:opacity-50"
disabled={isApplying}
>
{isApplying ? 'Applying...' : 'Apply & Generate Tasks'}
</button>
</div>
</div>
)}
{step === 'generate' && (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Template Applied Successfully!
</h4>
<p className="text-gray-600 dark:text-gray-400">
Your PRD has been created and tasks are being generated...
</p>
</div>
)}
</div>
</div>
);
};
export default NextTaskBanner;

View File

@@ -0,0 +1,567 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext';
import { IS_PLATFORM } from '../constants/config';
const Onboarding = ({ onComplete }) => {
const [currentStep, setCurrentStep] = useState(0);
const [gitName, setGitName] = useState('');
const [gitEmail, setGitEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [cursorAuthStatus, setCursorAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [codexAuthStatus, setCodexAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const { user } = useAuth();
const prevActiveLoginProviderRef = useRef(undefined);
useEffect(() => {
loadGitConfig();
}, []);
const loadGitConfig = async () => {
try {
const response = await authenticatedFetch('/api/user/git-config');
if (response.ok) {
const data = await response.json();
if (data.gitName) setGitName(data.gitName);
if (data.gitEmail) setGitEmail(data.gitEmail);
}
} catch (error) {
console.error('Error loading git config:', error);
}
};
useEffect(() => {
const prevProvider = prevActiveLoginProviderRef.current;
prevActiveLoginProviderRef.current = activeLoginProvider;
const isInitialMount = prevProvider === undefined;
const isModalClosing = prevProvider !== null && activeLoginProvider === null;
if (isInitialMount || isModalClosing) {
checkClaudeAuthStatus();
checkCursorAuthStatus();
checkCodexAuthStatus();
checkGeminiAuthStatus();
}
}, [activeLoginProvider]);
const checkProviderAuthStatus = async (provider, setter) => {
try {
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (response.ok) {
const data = await response.json();
setter({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setter({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error(`Error checking ${provider} auth status:`, error);
setter({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
const handleClaudeLogin = () => setActiveLoginProvider('claude');
const handleCursorLogin = () => setActiveLoginProvider('cursor');
const handleCodexLogin = () => setActiveLoginProvider('codex');
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
const handleLoginComplete = (exitCode) => {
if (exitCode === 0) {
if (activeLoginProvider === 'claude') {
checkClaudeAuthStatus();
} else if (activeLoginProvider === 'cursor') {
checkCursorAuthStatus();
} else if (activeLoginProvider === 'codex') {
checkCodexAuthStatus();
} else if (activeLoginProvider === 'gemini') {
checkGeminiAuthStatus();
}
}
};
const handleNextStep = async () => {
setError('');
// Step 0: Git config validation and submission
if (currentStep === 0) {
if (!gitName.trim() || !gitEmail.trim()) {
setError('Both git name and email are required');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(gitEmail)) {
setError('Please enter a valid email address');
return;
}
setIsSubmitting(true);
try {
// Save git config to backend (which will also apply git config --global)
const response = await authenticatedFetch('/api/user/git-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitName, gitEmail })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to save git configuration');
}
setCurrentStep(currentStep + 1);
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
return;
}
setCurrentStep(currentStep + 1);
};
const handlePrevStep = () => {
setError('');
setCurrentStep(currentStep - 1);
};
const handleFinish = async () => {
setIsSubmitting(true);
setError('');
try {
const response = await authenticatedFetch('/api/user/complete-onboarding', {
method: 'POST'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to complete onboarding');
}
if (onComplete) {
onComplete();
}
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
const steps = [
{
title: 'Git Configuration',
description: 'Set up your git identity for commits',
icon: GitBranch,
required: true
},
{
title: 'Connect Agents',
description: 'Connect your AI coding assistants',
icon: LogIn,
required: false
}
];
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<GitBranch className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Git Configuration</h2>
<p className="text-muted-foreground">
Configure your git identity to ensure proper attribution for your commits
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="gitName" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
<User className="w-4 h-4" />
Git Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="gitName"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="John Doe"
required
disabled={isSubmitting}
/>
<p className="mt-1 text-xs text-muted-foreground">
This will be used as: git config --global user.name
</p>
</div>
<div>
<label htmlFor="gitEmail" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
<Mail className="w-4 h-4" />
Git Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="gitEmail"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="john@example.com"
required
disabled={isSubmitting}
/>
<p className="mt-1 text-xs text-muted-foreground">
This will be used as: git config --global user.email
</p>
</div>
</div>
</div>
);
case 1:
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-foreground mb-2">Connect Your AI Agents</h2>
<p className="text-muted-foreground">
Login to one or more AI coding assistants. All are optional.
</p>
</div>
{/* Agent Cards Grid */}
<div className="space-y-3">
{/* Claude */}
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="claude" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Claude Code
{claudeAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{claudeAuthStatus.loading ? 'Checking...' :
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!claudeAuthStatus.authenticated && !claudeAuthStatus.loading && (
<button
onClick={handleClaudeLogin}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
{/* Cursor */}
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="cursor" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Cursor
{cursorAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{cursorAuthStatus.loading ? 'Checking...' :
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!cursorAuthStatus.authenticated && !cursorAuthStatus.loading && (
<button
onClick={handleCursorLogin}
className="bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
{/* Codex */}
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="codex" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
OpenAI Codex
{codexAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{codexAuthStatus.loading ? 'Checking...' :
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!codexAuthStatus.authenticated && !codexAuthStatus.loading && (
<button
onClick={handleCodexLogin}
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
{/* Gemini */}
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Gemini
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{geminiAuthStatus.loading ? 'Checking...' :
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
<button
onClick={handleGeminiLogin}
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground pt-2">
<p>You can configure these later in Settings.</p>
</div>
</div>
);
default:
return null;
}
};
const isStepValid = () => {
switch (currentStep) {
case 0:
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
case 1:
return true;
default:
return false;
}
};
return (
<>
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-2xl">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<React.Fragment key={index}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
'bg-background border-border text-muted-foreground'
}`}>
{index < currentStep ? (
<Check className="w-6 h-6" />
) : typeof step.icon === 'function' ? (
<step.icon />
) : (
<step.icon className="w-6 h-6" />
)}
</div>
<div className="mt-2 text-center">
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.title}
</p>
{step.required && (
<span className="text-xs text-red-500">Required</span>
)}
</div>
</div>
{index < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Main Card */}
<div className="bg-card rounded-lg shadow-lg border border-border p-8">
{renderStepContent()}
{/* Error Message */}
{error && (
<div className="mt-6 p-4 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{/* Navigation Buttons */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-border">
<button
onClick={handlePrevStep}
disabled={currentStep === 0 || isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<div className="flex items-center gap-3">
{currentStep < steps.length - 1 ? (
<button
onClick={handleNextStep}
disabled={!isStepValid() || isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
Next
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
) : (
<button
onClick={handleFinish}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Completing...
</>
) : (
<>
<Check className="w-4 h-4" />
Complete Setup
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
{activeLoginProvider && (
<LoginModal
isOpen={!!activeLoginProvider}
onClose={() => setActiveLoginProvider(null)}
provider={activeLoginProvider}
project={selectedProject}
onComplete={handleLoginComplete}
isOnboarding={true}
/>
)}
</>
);
};
export default Onboarding;

View File

@@ -0,0 +1,871 @@
import React, { useState, useEffect, useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { X, Save, Download, Maximize2, Minimize2, Eye, FileText, Sparkles, AlertTriangle } from 'lucide-react';
import { cn } from '../lib/utils';
import { api, authenticatedFetch } from '../utils/api';
const PRDEditor = ({
file,
onClose,
projectPath,
project, // Add project object
initialContent = '',
isNewFile = false,
onSave
}) => {
const [content, setContent] = useState(initialContent);
const [loading, setLoading] = useState(!isNewFile);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [saveSuccess, setSaveSuccess] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
const [wordWrap, setWordWrap] = useState(true); // Default to true for markdown
const [fileName, setFileName] = useState('');
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [existingPRDs, setExistingPRDs] = useState([]);
const editorRef = useRef(null);
const PRD_TEMPLATE = `# Product Requirements Document - Example Project
## 1. Overview
**Product Name:** AI-Powered Task Manager
**Version:** 1.0
**Date:** 2024-12-27
**Author:** Development Team
This document outlines the requirements for building an AI-powered task management application that integrates with development workflows and provides intelligent task breakdown and prioritization.
## 2. Objectives
- Create an intuitive task management system that works seamlessly with developer tools
- Provide AI-powered task generation from high-level requirements
- Enable real-time collaboration and progress tracking
- Integrate with popular development environments (VS Code, Cursor, etc.)
### Success Metrics
- User adoption rate > 80% within development teams
- Task completion rate improvement of 25%
- Time-to-delivery reduction of 15%
## 3. User Stories
### Core Functionality
- As a project manager, I want to create PRDs that automatically generate detailed tasks so I can save time on project planning
- As a developer, I want to see my next task clearly highlighted so I can maintain focus
- As a team lead, I want to track progress across multiple projects so I can provide accurate status updates
- As a developer, I want tasks to be broken down into implementable subtasks so I can work more efficiently
### AI Integration
- As a user, I want to describe a feature in natural language and get detailed implementation tasks so I can start working immediately
- As a project manager, I want the AI to analyze task complexity and suggest appropriate time estimates
- As a developer, I want intelligent task prioritization based on dependencies and deadlines
### Collaboration
- As a team member, I want to see real-time updates when tasks are completed so I can coordinate my work
- As a stakeholder, I want to view project progress through intuitive dashboards
- As a developer, I want to add implementation notes to tasks for future reference
## 4. Functional Requirements
### Task Management
- Create, edit, and delete tasks with rich metadata (priority, status, dependencies, estimates)
- Hierarchical task structure with subtasks and sub-subtasks
- Real-time status updates and progress tracking
- Dependency management with circular dependency detection
- Bulk operations (move, update status, assign)
### AI Features
- Natural language PRD parsing to generate structured tasks
- Intelligent task breakdown with complexity analysis
- Automated subtask generation with implementation details
- Smart dependency suggestion
- Progress prediction based on historical data
### Integration Features
- VS Code/Cursor extension for in-editor task management
- Git integration for linking commits to tasks
- API for third-party tool integration
- Webhook support for external notifications
- CLI tool for command-line task management
### User Interface
- Responsive web application (desktop and mobile)
- Multiple view modes (Kanban, list, calendar)
- Dark/light theme support
- Drag-and-drop task organization
- Advanced filtering and search capabilities
- Keyboard shortcuts for power users
## 5. Technical Requirements
### Frontend
- React.js with TypeScript for type safety
- Modern UI framework (Tailwind CSS)
- State management (Context API or Redux)
- Real-time updates via WebSockets
- Progressive Web App (PWA) support
- Accessibility compliance (WCAG 2.1 AA)
### Backend
- Node.js with Express.js framework
- RESTful API design with OpenAPI documentation
- Real-time communication via Socket.io
- Background job processing
- Rate limiting and security middleware
### AI Integration
- Integration with multiple AI providers (OpenAI, Anthropic, etc.)
- Fallback model support
- Context-aware prompt engineering
- Token usage optimization
- Model response caching
### Database
- Primary: PostgreSQL for relational data
- Cache: Redis for session management and real-time features
- Full-text search capabilities
- Database migrations and seeding
- Backup and recovery procedures
### Infrastructure
- Docker containerization
- Cloud deployment (AWS/GCP/Azure)
- Auto-scaling capabilities
- Monitoring and logging (structured logging)
- CI/CD pipeline with automated testing
## 6. Non-Functional Requirements
### Performance
- Page load time < 2 seconds
- API response time < 500ms for 95% of requests
- Support for 1000+ concurrent users
- Efficient handling of large task lists (10,000+ tasks)
### Security
- JWT-based authentication with refresh tokens
- Role-based access control (RBAC)
- Data encryption at rest and in transit
- Regular security audits and penetration testing
- GDPR and privacy compliance
### Reliability
- 99.9% uptime SLA
- Graceful error handling and recovery
- Data backup every 6 hours with point-in-time recovery
- Disaster recovery plan with RTO < 4 hours
### Scalability
- Horizontal scaling for both frontend and backend
- Database read replicas for query optimization
- CDN for static asset delivery
- Microservices architecture for future expansion
## 7. User Experience Design
### Information Architecture
- Intuitive navigation with breadcrumbs
- Context-aware menus and actions
- Progressive disclosure of complex features
- Consistent design patterns throughout
### Interaction Design
- Smooth animations and transitions
- Immediate feedback for user actions
- Undo/redo functionality for critical operations
- Smart defaults and auto-save features
### Visual Design
- Modern, clean interface with plenty of whitespace
- Consistent color scheme and typography
- Clear visual hierarchy with proper contrast ratios
- Iconography that supports comprehension
## 8. Integration Requirements
### Development Tools
- VS Code extension with task panel and quick actions
- Cursor IDE integration with AI task suggestions
- Terminal CLI for command-line workflow
- Browser extension for web-based tools
### Third-Party Services
- GitHub/GitLab integration for issue sync
- Slack/Discord notifications
- Calendar integration (Google Calendar, Outlook)
- Time tracking tools (Toggl, Harvest)
### APIs and Webhooks
- RESTful API with comprehensive documentation
- GraphQL endpoint for complex queries
- Webhook system for external integrations
- SDK development for major programming languages
## 9. Implementation Phases
### Phase 1: Core MVP (8-10 weeks)
- Basic task management (CRUD operations)
- Simple AI task generation
- Web interface with essential features
- User authentication and basic permissions
### Phase 2: Enhanced Features (6-8 weeks)
- Advanced AI features (complexity analysis, subtask generation)
- Real-time collaboration
- Mobile-responsive design
- Integration with one development tool (VS Code)
### Phase 3: Enterprise Features (4-6 weeks)
- Advanced user management and permissions
- API and webhook system
- Performance optimization
- Comprehensive testing and security audit
### Phase 4: Ecosystem Expansion (4-6 weeks)
- Additional tool integrations
- Mobile app development
- Advanced analytics and reporting
- Third-party marketplace preparation
## 10. Risk Assessment
### Technical Risks
- AI model reliability and cost management
- Real-time synchronization complexity
- Database performance with large datasets
- Integration complexity with multiple tools
### Business Risks
- User adoption in competitive market
- AI provider dependency
- Data privacy and security concerns
- Feature scope creep and timeline delays
### Mitigation Strategies
- Implement robust error handling and fallback systems
- Develop comprehensive testing strategy
- Create detailed documentation and user guides
- Establish clear project scope and change management process
## 11. Success Criteria
### Development Milestones
- Alpha version with core features completed
- Beta version with selected user group feedback
- Production-ready version with full feature set
- Post-launch iterations based on user feedback
### Business Metrics
- User engagement and retention rates
- Task completion and productivity metrics
- Customer satisfaction scores (NPS > 50)
- Revenue targets and subscription growth
## 12. Appendices
### Glossary
- **PRD**: Product Requirements Document
- **AI**: Artificial Intelligence
- **CRUD**: Create, Read, Update, Delete
- **API**: Application Programming Interface
- **CI/CD**: Continuous Integration/Continuous Deployment
### References
- Industry best practices for task management
- AI integration patterns and examples
- Security and compliance requirements
- Performance benchmarking data
---
**Document Control:**
- Version: 1.0
- Last Updated: December 27, 2024
- Next Review: January 15, 2025
- Approved By: Product Owner, Technical Lead`;
// Initialize filename and load content
useEffect(() => {
const initializeEditor = async () => {
// Set initial filename
if (file?.name) {
setFileName(file.name.replace(/\.(txt|md)$/, '')); // Remove extension for editing
} else if (isNewFile) {
// Generate default filename based on current date
const now = new Date();
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD
setFileName(`prd-${dateStr}`);
}
// Load content
if (isNewFile) {
setContent(PRD_TEMPLATE);
setLoading(false);
return;
}
// If content is directly provided (for existing PRDs loaded from API)
if (file.content) {
setContent(file.content);
setLoading(false);
return;
}
// Fallback to loading from file path (legacy support)
try {
setLoading(true);
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content || PRD_TEMPLATE);
} catch (error) {
console.error('Error loading PRD file:', error);
setContent(`# Error Loading PRD\n\nError: ${error.message}\n\nFile: ${file?.name || 'New PRD'}\nPath: ${file?.path || 'Not saved yet'}\n\n${PRD_TEMPLATE}`);
} finally {
setLoading(false);
}
};
initializeEditor();
}, [file, projectPath, isNewFile]);
// Fetch existing PRDs to check for conflicts
useEffect(() => {
const fetchExistingPRDs = async () => {
if (!project?.name) {
console.log('No project name available:', project);
return;
}
try {
console.log('Fetching PRDs for project:', project.name);
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
if (response.ok) {
const data = await response.json();
console.log('Fetched existing PRDs:', data.prds);
setExistingPRDs(data.prds || []);
} else {
console.log('Failed to fetch PRDs:', response.status, response.statusText);
}
} catch (error) {
console.error('Error fetching existing PRDs:', error);
}
};
fetchExistingPRDs();
}, [project?.name]);
const handleSave = async () => {
if (!content.trim()) {
alert('Please add content before saving.');
return;
}
if (!fileName.trim()) {
alert('Please provide a filename for the PRD.');
return;
}
// Check if file already exists
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
const existingFile = existingPRDs.find(prd => prd.name === fullFileName);
console.log('Save check:', {
fullFileName,
existingPRDs,
existingFile,
isExisting: file?.isExisting,
fileObject: file,
shouldShowModal: existingFile && !file?.isExisting
});
if (existingFile && !file?.isExisting) {
console.log('Showing overwrite confirmation modal');
// Show confirmation modal for overwrite
setShowOverwriteConfirm(true);
return;
}
await performSave();
};
const performSave = async () => {
setSaving(true);
try {
// Ensure filename has .txt extension
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(project?.name)}`, {
method: 'POST',
body: JSON.stringify({
fileName: fullFileName,
content
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Save failed: ${response.status}`);
}
// Show success feedback
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
// Update existing PRDs list
const response2 = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
if (response2.ok) {
const data = await response2.json();
setExistingPRDs(data.prds || []);
}
// Call the onSave callback if provided (for UI updates)
if (onSave) {
await onSave();
}
} catch (error) {
console.error('Error saving PRD:', error);
alert(`Error saving PRD: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const downloadFileName = fileName ? `${fileName}.txt` : 'prd.txt';
a.download = downloadFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleGenerateTasks = async () => {
if (!content.trim()) {
alert('Please add content to the PRD before generating tasks.');
return;
}
// Show AI-first modal instead of simple confirm
setShowGenerateModal(true);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
// Simple markdown to HTML converter for preview
const renderMarkdown = (markdown) => {
return markdown
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/^\- (.*$)/gim, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/gims, '<ul>$1</ul>')
.replace(/\n\n/gim, '</p><p>')
.replace(/^(?!<[h|u|l])(.*$)/gim, '<p>$1</p>')
.replace(/<\/ul>\s*<ul>/gim, '');
};
if (loading) {
return (
<div className="fixed inset-0 z-[200] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center bg-white dark:bg-gray-900">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading PRD...</span>
</div>
</div>
</div>
);
}
return (
<div className={`fixed inset-0 z-[200] ${
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={cn(
'bg-white dark:bg-gray-900 shadow-2xl flex flex-col',
'w-full h-full md:rounded-lg md:shadow-2xl',
isFullscreen
? 'md:w-full md:h-full md:rounded-none'
: 'md:w-full md:max-w-6xl md:h-[85vh] md:max-h-[85vh]'
)}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-white" />
</div>
<div className="min-w-0 flex-1">
{/* Mobile: Stack filename and tags vertically for more space */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0">
{/* Filename input row - full width on mobile */}
<div className="flex items-center gap-1 min-w-0 flex-1">
<div className="flex items-center min-w-0 flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-2 focus-within:ring-2 focus-within:ring-purple-500 focus-within:border-purple-500 dark:focus-within:ring-purple-400 dark:focus-within:border-purple-400">
<input
type="text"
value={fileName}
onChange={(e) => {
// Remove invalid filename characters
const sanitizedValue = e.target.value.replace(/[<>:"/\\|?*]/g, '');
setFileName(sanitizedValue);
}}
className="font-medium text-gray-900 dark:text-white bg-transparent border-none outline-none min-w-0 flex-1 text-base sm:text-sm placeholder-gray-400 dark:placeholder-gray-500"
placeholder="Enter PRD filename"
maxLength={100}
/>
<span className="text-sm sm:text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-1">.txt</span>
</div>
<button
onClick={() => document.querySelector('input[placeholder="Enter PRD filename"]')?.focus()}
className="p-1 text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
title="Click to edit filename"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
{/* Tags row - moves to second line on mobile for more filename space */}
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 px-2 py-1 rounded whitespace-nowrap">
📋 PRD
</span>
{isNewFile && (
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 px-2 py-1 rounded whitespace-nowrap">
New
</span>
)}
</div>
</div>
{/* Description - smaller on mobile */}
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate mt-1">
Product Requirements Document
</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
<button
onClick={() => setPreviewMode(!previewMode)}
className={cn(
'p-2 md:p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800',
'min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center',
previewMode
? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
)}
title={previewMode ? 'Switch to edit mode' : 'Preview markdown'}
>
<Eye className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={() => setWordWrap(!wordWrap)}
className={cn(
'p-2 md:p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800',
'min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center',
wordWrap
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
)}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Toggle theme"
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download PRD"
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={handleGenerateTasks}
disabled={!content.trim()}
className={cn(
'px-3 py-2 rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors text-sm font-medium',
'bg-purple-600 hover:bg-purple-700 text-white',
'min-h-[44px] md:min-h-0'
)}
title="Generate tasks from PRD content"
>
<Sparkles className="w-4 h-4" />
<span className="hidden md:inline">Generate Tasks</span>
</button>
<button
onClick={handleSave}
disabled={saving}
className={cn(
'px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors',
'min-h-[44px] md:min-h-0',
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-purple-600 hover:bg-purple-700'
)}
>
{saveSuccess ? (
<>
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
</>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save PRD'}</span>
</>
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
>
<X className="w-6 h-6 md:w-4 md:h-4" />
</button>
</div>
</div>
{/* Editor/Preview Content */}
<div className="flex-1 overflow-hidden">
{previewMode ? (
<div className="h-full overflow-y-auto p-6 prose prose-gray dark:prose-invert max-w-none">
<div
className="markdown-preview"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
) : (
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
markdown(),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: '14px',
height: '100%',
}}
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Words: {content.split(/\s+/).filter(word => word.length > 0).length}</span>
<span>Format: Markdown</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
</div>
</div>
</div>
{/* Generate Tasks Modal */}
{showGenerateModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Generate Tasks from PRD</h3>
</div>
<button
onClick={() => setShowGenerateModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* AI-First Approach */}
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-purple-900 dark:text-purple-100 mb-2">
💡 Pro Tip: Ask Claude Code Directly!
</h4>
<p className="text-sm text-purple-800 dark:text-purple-200 mb-3">
You can simply ask Claude Code in the chat to parse your PRD and generate tasks.
The AI assistant will automatically save your PRD and create detailed tasks with implementation details.
</p>
<div className="bg-white dark:bg-gray-800 rounded border border-purple-200 dark:border-purple-700 p-3 mb-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
<p className="text-xs text-gray-900 dark:text-white font-mono">
"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}. Can you help me parse it and set up the initial tasks?"
</p>
</div>
<p className="text-xs text-purple-700 dark:text-purple-300">
<strong>This will:</strong> Save your PRD, analyze its content, and generate structured tasks with subtasks, dependencies, and implementation details.
</p>
</div>
</div>
</div>
{/* Learn More Link */}
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
For more examples and advanced usage patterns:
</p>
<a
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 underline font-medium"
>
View TaskMaster Documentation
</a>
</div>
{/* Footer */}
<div className="pt-4">
<button
onClick={() => setShowGenerateModal(false)}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Got it, I'll ask Claude Code directly
</button>
</div>
</div>
</div>
</div>
)}
{/* Overwrite Confirmation Modal */}
{showOverwriteConfirm && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowOverwriteConfirm(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-gray-700">
<div className="p-6">
<div className="flex items-center mb-4">
<div className="p-2 rounded-full mr-3 bg-yellow-100 dark:bg-yellow-900">
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
File Already Exists
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
A PRD file named "{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}" already exists.
Do you want to overwrite it with the current content?
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setShowOverwriteConfirm(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
setShowOverwriteConfirm(false);
await performSave();
}}
className="px-4 py-2 text-sm text-white bg-yellow-600 hover:bg-yellow-700 rounded-md flex items-center space-x-2 transition-colors"
>
<Save className="w-4 h-4" />
<span>Overwrite</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default PRDEditor;

View File

@@ -0,0 +1,875 @@
import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const { t } = useTranslation();
// Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
// Form state
const [workspacePath, setWorkspacePath] = useState('');
const [githubUrl, setGithubUrl] = useState('');
const [selectedGithubToken, setSelectedGithubToken] = useState('');
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
const [newGithubToken, setNewGithubToken] = useState('');
// UI state
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState(null);
const [availableTokens, setAvailableTokens] = useState([]);
const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
const [cloneProgress, setCloneProgress] = useState('');
// Load available GitHub tokens when needed
useEffect(() => {
if (step === 2 && workspaceType === 'new' && githubUrl) {
loadGithubTokens();
}
}, [step, workspaceType, githubUrl]);
// Load path suggestions
useEffect(() => {
if (workspacePath.length > 2) {
loadPathSuggestions(workspacePath);
} else {
setPathSuggestions([]);
setShowPathDropdown(false);
}
}, [workspacePath]);
const loadGithubTokens = async () => {
try {
setLoadingTokens(true);
const response = await api.get('/settings/credentials?type=github_token');
const data = await response.json();
const activeTokens = (data.credentials || []).filter(t => t.is_active);
setAvailableTokens(activeTokens);
// Auto-select first token if available
if (activeTokens.length > 0 && !selectedGithubToken) {
setSelectedGithubToken(activeTokens[0].id.toString());
}
} catch (error) {
console.error('Error loading GitHub tokens:', error);
} finally {
setLoadingTokens(false);
}
};
const loadPathSuggestions = async (inputPath) => {
try {
// Extract the directory to browse (parent of input)
const lastSlash = inputPath.lastIndexOf('/');
const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
const response = await api.browseFilesystem(dirPath);
const data = await response.json();
if (data.suggestions) {
// Filter suggestions based on the input, excluding exact match
const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
s.path.toLowerCase() !== inputPath.toLowerCase()
);
setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0);
}
} catch (error) {
console.error('Error loading path suggestions:', error);
}
};
const handleNext = () => {
setError(null);
if (step === 1) {
if (!workspaceType) {
setError(t('projectWizard.errors.selectType'));
return;
}
setStep(2);
} else if (step === 2) {
if (!workspacePath.trim()) {
setError(t('projectWizard.errors.providePath'));
return;
}
// No validation for GitHub token - it's optional (only needed for private repos)
setStep(3);
}
};
const handleBack = () => {
setError(null);
setStep(step - 1);
};
const handleCreate = async () => {
setIsCreating(true);
setError(null);
setCloneProgress('');
try {
if (workspaceType === 'new' && githubUrl) {
const params = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
params.append('githubTokenId', selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
params.append('newGithubToken', newGithubToken.trim());
}
const token = localStorage.getItem('auth-token');
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
await new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setCloneProgress(data.message);
} else if (data.type === 'complete') {
eventSource.close();
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
resolve();
} else if (data.type === 'error') {
eventSource.close();
reject(new Error(data.message));
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
reject(new Error('Connection lost during clone'));
};
});
return;
}
const payload = {
workspaceType,
path: workspacePath.trim(),
};
const response = await api.createWorkspace(payload);
const data = await response.json();
if (!response.ok) {
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
}
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
} catch (error) {
console.error('Error creating workspace:', error);
setError(error.message || t('projectWizard.errors.failedToCreate'));
} finally {
setIsCreating(false);
}
};
const selectPathSuggestion = (suggestion) => {
setWorkspacePath(suggestion.path);
setShowPathDropdown(false);
};
const openFolderBrowser = async () => {
setShowFolderBrowser(true);
await loadBrowserFolders('~');
};
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
const response = await api.browseFilesystem(path);
const data = await response.json();
setBrowserCurrentPath(data.path || path);
setBrowserFolders(data.suggestions || []);
} catch (error) {
console.error('Error loading folders:', error);
} finally {
setLoadingFolders(false);
}
};
const selectFolder = (folderPath, advanceToConfirm = false) => {
setWorkspacePath(folderPath);
setShowFolderBrowser(false);
if (advanceToConfirm) {
setStep(3);
}
};
const navigateToFolder = async (folderPath) => {
await loadBrowserFolders(folderPath);
};
const createNewFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
setError(null);
try {
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
const response = await api.createFolder(folderPath);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
}
setNewFolderName('');
setShowNewFolderInput(false);
await loadBrowserFolders(data.path || folderPath);
} catch (error) {
console.error('Error creating folder:', error);
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
} finally {
setCreatingFolder(false);
}
};
return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('projectWizard.title')}
</h3>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
disabled={isCreating}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Indicator */}
<div className="px-6 pt-4 pb-2">
<div className="flex items-center justify-between">
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div className="flex items-center gap-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
s < step
? 'bg-green-500 text-white'
: s === step
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
{s < step ? <Check className="w-4 h-4" /> : s}
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{s === 1 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
</span>
</div>
{s < 3 && (
<div
className={`flex-1 h-1 mx-2 rounded ${
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6 min-h-[300px]">
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{/* Step 1: Choose workspace type */}
{step === 1 && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('projectWizard.step1.question')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Workspace */}
<button
onClick={() => setWorkspaceType('existing')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
workspaceType === 'existing'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('projectWizard.step1.existing.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step1.existing.description')}
</p>
</div>
</div>
</button>
{/* New Workspace */}
<button
onClick={() => setWorkspaceType('new')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
workspaceType === 'new'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('projectWizard.step1.new.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step1.new.description')}
</p>
</div>
</div>
</button>
</div>
</div>
</div>
)}
{/* Step 2: Configure workspace */}
{step === 2 && (
<div className="space-y-4">
{/* Workspace Path */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? t('projectWizard.step2.existingPath') : t('projectWizard.step2.newPath')}
</label>
<div className="relative flex gap-2">
<div className="flex-1 relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={openFolderBrowser}
className="px-3"
title="Browse folders"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
? t('projectWizard.step2.existingHelp')
: t('projectWizard.step2.newHelp')}
</p>
</div>
{/* GitHub URL (only for new workspace) */}
{workspaceType === 'new' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.githubUrl')}
</label>
<Input
type="text"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/username/repository"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.githubHelp')}
</p>
</div>
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-3 mb-4">
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
{t('projectWizard.step2.githubAuth')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step2.githubAuthHelp')}
</p>
</div>
</div>
{loadingTokens ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t('projectWizard.step2.loadingTokens')}
</div>
) : availableTokens.length > 0 ? (
<>
{/* Token Selection Tabs */}
<div className="grid grid-cols-3 gap-2 mb-4">
<button
onClick={() => setTokenMode('stored')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'stored'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{t('projectWizard.step2.storedToken')}
</button>
<button
onClick={() => setTokenMode('new')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'new'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{t('projectWizard.step2.newToken')}
</button>
<button
onClick={() => {
setTokenMode('none');
setSelectedGithubToken('');
setNewGithubToken('');
}}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'none'
? 'bg-green-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{t('projectWizard.step2.nonePublic')}
</button>
</div>
{tokenMode === 'stored' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.selectToken')}
</label>
<select
value={selectedGithubToken}
onChange={(e) => setSelectedGithubToken(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
>
<option value="">{t('projectWizard.step2.selectTokenPlaceholder')}</option>
{availableTokens.map((token) => (
<option key={token.id} value={token.id}>
{token.credential_name}
</option>
))}
</select>
</div>
) : tokenMode === 'new' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.newToken')}
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.tokenHelp')}
</p>
</div>
) : null}
</>
) : (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
{t('projectWizard.step2.publicRepoInfo')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.optionalTokenPublic')}
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.noTokensHelp')}
</p>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t('projectWizard.step3.reviewConfig')}
</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.workspaceType')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{workspacePath}
</span>
</div>
{workspaceType === 'new' && githubUrl && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.cloneFrom')}</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{githubUrl}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.authentication')}</span>
<span className="text-xs text-gray-900 dark:text-white">
{tokenMode === 'stored' && selectedGithubToken
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken
? t('projectWizard.step3.usingProvidedToken')
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
? t('projectWizard.step3.sshKey', 'SSH Key')
: t('projectWizard.step3.noAuthentication')}
</span>
</div>
</>
)}
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
{isCreating && cloneProgress ? (
<div className="space-y-2">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
{cloneProgress}
</code>
</div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? t('projectWizard.step3.existingInfo')
: githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
</p>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={step === 1 ? onClose : handleBack}
disabled={isCreating}
>
{step === 1 ? (
t('projectWizard.buttons.cancel')
) : (
<>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('projectWizard.buttons.back')}
</>
)}
</Button>
<Button
onClick={step === 3 ? handleCreate : handleNext}
disabled={isCreating || (step === 1 && !workspaceType)}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
</>
) : step === 3 ? (
<>
<Check className="w-4 h-4 mr-1" />
{t('projectWizard.buttons.createProject')}
</>
) : (
<>
{t('projectWizard.buttons.next')}
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
{/* Browser Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Folder
</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
className={`p-2 rounded-md transition-colors ${
showHiddenFolders
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
>
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button>
<button
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
className={`p-2 rounded-md transition-colors ${
showNewFolderInput
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Create new folder"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={() => setShowFolderBrowser(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* New Folder Input */}
{showNewFolderInput && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2">
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') createNewFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
autoFocus
/>
<Button
size="sm"
onClick={createNewFolder}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Folder List */}
<div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : (
<div className="space-y-1">
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
<button
onClick={() => {
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
let parentPath;
if (lastSlash <= 0) {
parentPath = '/';
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
parentPath = browserCurrentPath.substring(0, 3);
} else {
parentPath = browserCurrentPath.substring(0, lastSlash);
}
navigateToFolder(parentPath);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderOpen className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
</button>
)}
{/* Folders */}
{browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No subfolders found
</div>
) : (
browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
className="text-xs px-3"
>
Select
</Button>
</div>
))
)}
</div>
)}
</div>
{/* Browser Footer with Current Path */}
<div className="border-t border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
{browserCurrentPath}
</code>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<Button
variant="outline"
onClick={() => {
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
>
Use this folder
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ProjectCreationWizard;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import Onboarding from './Onboarding';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../constants/config';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
);
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (IS_PLATFORM) {
if (isLoading) {
return <LoadingScreen />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return children;
}
if (isLoading) {
return <LoadingScreen />;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,448 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
Settings2,
Moon,
Sun,
ArrowDown,
Mic,
Brain,
Sparkles,
FileText,
Languages,
GripVertical
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from './DarkModeToggle';
import { useUiPreferences } from '../hooks/useUiPreferences';
import { useTheme } from '../contexts/ThemeContext';
import LanguageSelector from './LanguageSelector';
import { useDeviceSettings } from '../hooks/useDeviceSettings';
const QuickSettingsPanel = () => {
const { t } = useTranslation('settings');
const [isOpen, setIsOpen] = useState(false);
const [whisperMode, setWhisperMode] = useState(() => {
return localStorage.getItem('whisperMode') || 'default';
});
const { isDarkMode } = useTheme();
const { isMobile } = useDeviceSettings({ trackPWA: false });
const { preferences, setPreference } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed.y ?? 50;
} catch {
// Remove corrupted data
localStorage.removeItem('quickSettingsHandlePosition');
return 50;
}
}
return 50; // Default to 50% (middle of screen)
});
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
const handleRef = useRef(null);
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
}, [handlePosition]);
// Calculate position from percentage
const getPositionStyle = useCallback(() => {
if (isMobile) {
// On mobile, convert percentage to pixels from bottom
const bottomPixels = (window.innerHeight * handlePosition) / 100;
return { bottom: `${bottomPixels}px` };
} else {
// On desktop, use top with percentage
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
}
}, [handlePosition, isMobile]);
// Handle mouse/touch start
const handleDragStart = useCallback((e) => {
// Don't prevent default yet - we want to allow click if no drag happens
e.stopPropagation();
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
setDragStartY(clientY);
setDragStartPosition(handlePosition);
setHasMoved(false);
setIsDragging(false); // Don't set dragging until threshold is passed
}, [handlePosition]);
// Handle mouse/touch move
const handleDragMove = useCallback((e) => {
if (dragStartY === 0) return; // Not in a potential drag
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaY = Math.abs(clientY - dragStartY);
// Check if we've moved past threshold
if (!isDragging && deltaY > dragThreshold) {
setIsDragging(true);
setHasMoved(true);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
// Prevent body scroll on mobile during drag
if (e.type.includes('touch')) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
}
if (!isDragging) return;
// Prevent scrolling on touch move
if (e.type.includes('touch')) {
e.preventDefault();
}
const actualDeltaY = clientY - dragStartY;
// For top-based positioning (desktop), moving down increases top percentage
// For bottom-based positioning (mobile), we need to invert
let percentageDelta;
if (isMobile) {
// On mobile, moving down should decrease bottom position (increase percentage from top)
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
} else {
// On desktop, moving down should increase top position
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
}
let newPosition = dragStartPosition + percentageDelta;
// Apply constraints
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
setHandlePosition(newPosition);
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
// Handle mouse/touch end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragStartY(0);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restore body scroll on mobile
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}, []);
// Cleanup body styles on unmount in case component unmounts while dragging
useEffect(() => {
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, []);
// Set up global event listeners for drag
useEffect(() => {
if (dragStartY !== 0) {
// Mouse events
const handleMouseMove = (e) => handleDragMove(e);
const handleMouseUp = () => handleDragEnd();
// Touch events
const handleTouchMove = (e) => handleDragMove(e);
const handleTouchEnd = () => handleDragEnd();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [dragStartY, handleDragMove, handleDragEnd]);
const handleToggle = (e) => {
// Don't toggle if user was dragging
if (hasMoved) {
e.preventDefault();
setHasMoved(false);
return;
}
setIsOpen((previous) => !previous);
};
return (
<>
{/* Pull Tab - Combined drag handle and toggle button */}
<button
ref={handleRef}
onClick={handleToggle}
onMouseDown={(e) => {
// Start drag on mousedown
handleDragStart(e);
}}
onTouchStart={(e) => {
// Start drag on touchstart
handleDragStart(e);
}}
className={`fixed ${
isOpen ? 'right-64' : 'right-0'
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
) : isOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* Panel */}
<div
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
} ${isMobile ? 'h-screen' : ''}`}
>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
{t('quickSettings.title')}
</h3>
</div>
{/* Settings Content */}
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
{/* Appearance Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
{t('quickSettings.darkMode')}
</span>
<DarkModeToggle />
</div>
{/* Language Selector */}
<div>
<LanguageSelector compact={true} />
</div>
</div>
{/* Tool Display Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.autoExpandTools')}
</span>
<input
type="checkbox"
checked={autoExpandTools}
onChange={(e) => setPreference('autoExpandTools', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.showRawParameters')}
</span>
<input
type="checkbox"
checked={showRawParameters}
onChange={(e) => setPreference('showRawParameters', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.showThinking')}
</span>
<input
type="checkbox"
checked={showThinking}
onChange={(e) => setPreference('showThinking', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
{/* View Options */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.autoScrollToBottom')}
</span>
<input
type="checkbox"
checked={autoScrollToBottom}
onChange={(e) => setPreference('autoScrollToBottom', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.sendByCtrlEnter')}
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => setPreference('sendByCtrlEnter', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
{t('quickSettings.sendByCtrlEnterDescription')}
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
<div className="space-y-2">
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<input
type="radio"
name="whisperMode"
value="default"
checked={whisperMode === 'default'}
onChange={() => {
setWhisperMode('default');
localStorage.setItem('whisperMode', 'default');
window.dispatchEvent(new Event('whisperModeChanged'));
}}
className="mt-0.5 h-4 w-4 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"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.whisper.modes.default')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('quickSettings.whisper.modes.defaultDescription')}
</p>
</div>
</label>
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<input
type="radio"
name="whisperMode"
value="prompt"
checked={whisperMode === 'prompt'}
onChange={() => {
setWhisperMode('prompt');
localStorage.setItem('whisperMode', 'prompt');
window.dispatchEvent(new Event('whisperModeChanged'));
}}
className="mt-0.5 h-4 w-4 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"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.whisper.modes.prompt')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('quickSettings.whisper.modes.promptDescription')}
</p>
</div>
</label>
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<input
type="radio"
name="whisperMode"
value="vibe"
checked={whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect'}
onChange={() => {
setWhisperMode('vibe');
localStorage.setItem('whisperMode', 'vibe');
window.dispatchEvent(new Event('whisperModeChanged'));
}}
className="mt-0.5 h-4 w-4 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"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.whisper.modes.vibe')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('quickSettings.whisper.modes.vibeDescription')}
</p>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
onClick={handleToggle}
/>
)}
</>
);
};
export default QuickSettingsPanel;

View File

@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
const SetupForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { register } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
setIsLoading(true);
const result = await register(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
<p className="text-muted-foreground mt-2">
Set up your account to get started
</p>
</div>
{/* Setup Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Setting up...' : 'Create Account'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
This is a single-user system. Only one account can be created.
</p>
</div>
</div>
</div>
</div>
);
};
export default SetupForm;

210
src/components/TaskCard.jsx Normal file
View File

@@ -0,0 +1,210 @@
import React from 'react';
import { Clock, CheckCircle, Circle, AlertCircle, Pause, X, ArrowRight, ChevronUp, Minus, Flag } from 'lucide-react';
import { cn } from '../lib/utils';
import Tooltip from './Tooltip';
const TaskCard = ({
task,
onClick,
showParent = false,
className = ''
}) => {
const getStatusConfig = (status) => {
switch (status) {
case 'done':
return {
icon: CheckCircle,
bgColor: 'bg-green-50 dark:bg-green-950',
borderColor: 'border-green-200 dark:border-green-800',
iconColor: 'text-green-600 dark:text-green-400',
textColor: 'text-green-900 dark:text-green-100',
statusText: 'Done'
};
case 'in-progress':
return {
icon: Clock,
bgColor: 'bg-blue-50 dark:bg-blue-950',
borderColor: 'border-blue-200 dark:border-blue-800',
iconColor: 'text-blue-600 dark:text-blue-400',
textColor: 'text-blue-900 dark:text-blue-100',
statusText: 'In Progress'
};
case 'review':
return {
icon: AlertCircle,
bgColor: 'bg-amber-50 dark:bg-amber-950',
borderColor: 'border-amber-200 dark:border-amber-800',
iconColor: 'text-amber-600 dark:text-amber-400',
textColor: 'text-amber-900 dark:text-amber-100',
statusText: 'Review'
};
case 'deferred':
return {
icon: Pause,
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-gray-200 dark:border-gray-700',
iconColor: 'text-gray-500 dark:text-gray-400',
textColor: 'text-gray-700 dark:text-gray-300',
statusText: 'Deferred'
};
case 'cancelled':
return {
icon: X,
bgColor: 'bg-red-50 dark:bg-red-950',
borderColor: 'border-red-200 dark:border-red-800',
iconColor: 'text-red-600 dark:text-red-400',
textColor: 'text-red-900 dark:text-red-100',
statusText: 'Cancelled'
};
case 'pending':
default:
return {
icon: Circle,
bgColor: 'bg-slate-50 dark:bg-slate-800',
borderColor: 'border-slate-200 dark:border-slate-700',
iconColor: 'text-slate-500 dark:text-slate-400',
textColor: 'text-slate-900 dark:text-slate-100',
statusText: 'Pending'
};
}
};
const config = getStatusConfig(task.status);
const Icon = config.icon;
const getPriorityIcon = (priority) => {
switch (priority) {
case 'high':
return (
<Tooltip content="High Priority">
<div className="w-4 h-4 bg-red-100 dark:bg-red-900/30 rounded flex items-center justify-center">
<ChevronUp className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</div>
</Tooltip>
);
case 'medium':
return (
<Tooltip content="Medium Priority">
<div className="w-4 h-4 bg-amber-100 dark:bg-amber-900/30 rounded flex items-center justify-center">
<Minus className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
</div>
</Tooltip>
);
case 'low':
return (
<Tooltip content="Low Priority">
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center">
<Circle className="w-1.5 h-1.5 text-blue-600 dark:text-blue-400 fill-current" />
</div>
</Tooltip>
);
default:
return (
<Tooltip content="No Priority Set">
<div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<Circle className="w-1.5 h-1.5 text-gray-400 dark:text-gray-500" />
</div>
</Tooltip>
);
}
};
return (
<div
className={cn(
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200 cursor-pointer',
'p-3 space-y-3',
onClick && 'hover:-translate-y-0.5',
className
)}
onClick={onClick}
>
{/* Header with Task ID, Title, and Priority */}
<div className="flex items-start justify-between gap-2 mb-2">
{/* Task ID and Title */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Tooltip content={`Task ID: ${task.id}`}>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{task.id}
</span>
</Tooltip>
</div>
<h3 className="font-medium text-sm text-gray-900 dark:text-white line-clamp-2 leading-tight">
{task.title}
</h3>
{showParent && task.parentId && (
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
Task {task.parentId}
</span>
)}
</div>
{/* Priority Icon */}
<div className="flex-shrink-0">
{getPriorityIcon(task.priority)}
</div>
</div>
{/* Footer with Dependencies and Status */}
<div className="flex items-center justify-between">
{/* Dependencies */}
<div className="flex items-center">
{task.dependencies && Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
<Tooltip content={`Depends on: ${task.dependencies.map(dep => `Task ${dep}`).join(', ')}`}>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<ArrowRight className="w-3 h-3" />
<span>Depends on: {task.dependencies.join(', ')}</span>
</div>
</Tooltip>
)}
</div>
{/* Status Badge */}
<Tooltip content={`Status: ${config.statusText}`}>
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-full', config.iconColor.replace('text-', 'bg-'))} />
<span className={cn('text-xs font-medium', config.textColor)}>
{config.statusText}
</span>
</div>
</Tooltip>
</div>
{/* Subtask Progress (if applicable) */}
{task.subtasks && task.subtasks.length > 0 && (
<div className="ml-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} of ${task.subtasks.length} subtasks completed`}>
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
task.status === 'done' ? 'bg-green-500' : 'bg-blue-500'
)}
style={{
width: `${Math.round((task.subtasks.filter(st => st.status === 'done').length / task.subtasks.length) * 100)}%`
}}
/>
</div>
</Tooltip>
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} completed, ${task.subtasks.filter(st => st.status === 'pending').length} pending, ${task.subtasks.filter(st => st.status === 'in-progress').length} in progress`}>
<span className="text-xs text-gray-500 dark:text-gray-400">
{task.subtasks.filter(st => st.status === 'done').length}/{task.subtasks.length}
</span>
</Tooltip>
</div>
</div>
)}
</div>
);
};
export default TaskCard;

View File

@@ -0,0 +1,407 @@
import React, { useState } from 'react';
import { X, Flag, User, ArrowRight, CheckCircle, Circle, AlertCircle, Pause, Edit, Save, Copy, ChevronDown, ChevronRight, Clock } from 'lucide-react';
import { cn } from '../lib/utils';
import TaskIndicator from './TaskIndicator';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskDetail = ({
task,
onClose,
onEdit,
onStatusChange,
onTaskClick,
isOpen = true,
className = ''
}) => {
const [editMode, setEditMode] = useState(false);
const [editedTask, setEditedTask] = useState(task || {});
const [isSaving, setIsSaving] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showTestStrategy, setShowTestStrategy] = useState(false);
const { currentProject, refreshTasks } = useTaskMaster();
if (!isOpen || !task) return null;
const handleSave = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
// Only include changed fields
const updates = {};
if (editedTask.title !== task.title) updates.title = editedTask.title;
if (editedTask.description !== task.description) updates.description = editedTask.description;
if (editedTask.details !== task.details) updates.details = editedTask.details;
if (Object.keys(updates).length > 0) {
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
if (response.ok) {
// Refresh tasks to get updated data
refreshTasks?.();
onEdit?.(editedTask);
setEditMode(false);
} else {
const error = await response.json();
console.error('Failed to update task:', error);
alert(`Failed to update task: ${error.message}`);
}
} else {
setEditMode(false);
}
} catch (error) {
console.error('Error updating task:', error);
alert('Error updating task. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleStatusChange = async (newStatus) => {
if (!currentProject) return;
try {
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: newStatus });
if (response.ok) {
refreshTasks?.();
onStatusChange?.(task.id, newStatus);
} else {
const error = await response.json();
console.error('Failed to update task status:', error);
alert(`Failed to update task status: ${error.message}`);
}
} catch (error) {
console.error('Error updating task status:', error);
alert('Error updating task status. Please try again.');
}
};
const copyTaskId = () => {
copyTextToClipboard(task.id.toString());
};
const getStatusConfig = (status) => {
switch (status) {
case 'done':
return { icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-950' };
case 'in-progress':
return { icon: Clock, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950' };
case 'review':
return { icon: AlertCircle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950' };
case 'deferred':
return { icon: Pause, color: 'text-gray-500 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-800' };
case 'cancelled':
return { icon: X, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' };
default:
return { icon: Circle, color: 'text-slate-500 dark:text-slate-400', bg: 'bg-slate-50 dark:bg-slate-800' };
}
};
const statusConfig = getStatusConfig(task.status);
const StatusIcon = statusConfig.icon;
const getPriorityColor = (priority) => {
switch (priority) {
case 'high': return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
case 'medium': return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
case 'low': return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
default: return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
}
};
const statusOptions = [
{ value: 'pending', label: 'Pending' },
{ value: 'in-progress', label: 'In Progress' },
{ value: 'review', label: 'Review' },
{ value: 'done', label: 'Done' },
{ value: 'deferred', label: 'Deferred' },
{ value: 'cancelled', label: 'Cancelled' }
];
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
<div className={cn(
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
className
)}>
{/* Header */}
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusIcon className={cn('w-6 h-6', statusConfig.color)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<button
onClick={copyTaskId}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="Click to copy task ID"
>
<span>Task {task.id}</span>
<Copy className="w-3 h-3" />
</button>
{task.parentId && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Subtask of Task {task.parentId}
</span>
)}
</div>
{editMode ? (
<input
type="text"
value={editedTask.title || ''}
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
className="w-full text-lg font-semibold bg-transparent border-b-2 border-blue-500 focus:outline-none text-gray-900 dark:text-white"
placeholder="Task title"
/>
) : (
<h1 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-white line-clamp-2">
{task.title}
</h1>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{editMode ? (
<>
<button
onClick={handleSave}
disabled={isSaving}
className="p-2 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={isSaving ? "Saving..." : "Save changes"}
>
<Save className={cn("w-5 h-5", isSaving && "animate-spin")} />
</button>
<button
onClick={() => {
setEditMode(false);
setEditedTask(task);
}}
disabled={isSaving}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel editing"
>
<X className="w-5 h-5" />
</button>
</>
) : (
<button
onClick={() => setEditMode(true)}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
title="Edit task"
>
<Edit className="w-5 h-5" />
</button>
)}
<button
onClick={onClose}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
title="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 min-h-0">
{/* Status and Metadata Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Status */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<div className={cn(
'w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600',
statusConfig.bg,
statusConfig.color
)}>
<div className="flex items-center gap-2">
<StatusIcon className="w-4 h-4" />
<span className="font-medium capitalize">
{statusOptions.find(option => option.value === task.status)?.label || task.status}
</span>
</div>
</div>
</div>
{/* Priority */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<div className={cn(
'px-3 py-2 rounded-md text-sm font-medium capitalize',
getPriorityColor(task.priority)
)}>
<Flag className="w-4 h-4 inline mr-2" />
{task.priority || 'Not set'}
</div>
</div>
{/* Dependencies */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
{task.dependencies && task.dependencies.length > 0 ? (
<div className="flex flex-wrap gap-1">
{task.dependencies.map(depId => (
<button
key={depId}
onClick={() => onTaskClick && onTaskClick({ id: depId })}
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-sm hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
disabled={!onTaskClick}
title={onTaskClick ? `Click to view Task ${depId}` : `Task ${depId}`}
>
<ArrowRight className="w-3 h-3 inline mr-1" />
{depId}
</button>
))}
</div>
) : (
<span className="text-gray-500 dark:text-gray-400 text-sm">No dependencies</span>
)}
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
{editMode ? (
<textarea
value={editedTask.description || ''}
onChange={(e) => setEditedTask({ ...editedTask, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Task description"
/>
) : (
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{task.description || 'No description provided'}
</p>
)}
</div>
{/* Implementation Details */}
{task.details && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
<button
onClick={() => setShowDetails(!showDetails)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Implementation Details
</span>
{showDetails ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
{showDetails && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
{editMode ? (
<textarea
value={editedTask.details || ''}
onChange={(e) => setEditedTask({ ...editedTask, details: e.target.value })}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Implementation details"
/>
) : (
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{task.details}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Test Strategy */}
{task.testStrategy && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
<button
onClick={() => setShowTestStrategy(!showTestStrategy)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Test Strategy
</span>
{showTestStrategy ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
{showTestStrategy && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
<div className="bg-blue-50 dark:bg-blue-950 rounded-md p-4">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{task.testStrategy}
</p>
</div>
</div>
)}
</div>
)}
{/* Subtasks */}
{task.subtasks && task.subtasks.length > 0 && (
<div className="space-y-3">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Subtasks ({task.subtasks.length})
</label>
<div className="space-y-2">
{task.subtasks.map(subtask => {
const subtaskConfig = getStatusConfig(subtask.status);
const SubtaskIcon = subtaskConfig.icon;
return (
<div key={subtask.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<SubtaskIcon className={cn('w-4 h-4', subtaskConfig.color)} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white truncate">
{subtask.title}
</h4>
{subtask.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{subtask.description}
</p>
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{subtask.id}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="text-sm text-gray-500 dark:text-gray-400">
Task ID: {task.id}
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default TaskDetail;

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { CheckCircle, Settings, X, AlertCircle } from 'lucide-react';
import { cn } from '../lib/utils';
/**
* TaskIndicator Component
*
* Displays TaskMaster status for projects in the sidebar with appropriate
* icons and colors based on the project's TaskMaster configuration state.
*/
const TaskIndicator = ({
status = 'not-configured',
size = 'sm',
className = '',
showLabel = false
}) => {
const getIndicatorConfig = () => {
switch (status) {
case 'fully-configured':
return {
icon: CheckCircle,
color: 'text-green-500 dark:text-green-400',
bgColor: 'bg-green-50 dark:bg-green-950',
label: 'TaskMaster Ready',
title: 'TaskMaster fully configured with MCP server'
};
case 'taskmaster-only':
return {
icon: Settings,
color: 'text-blue-500 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-950',
label: 'TaskMaster Init',
title: 'TaskMaster initialized, MCP server needs setup'
};
case 'mcp-only':
return {
icon: AlertCircle,
color: 'text-amber-500 dark:text-amber-400',
bgColor: 'bg-amber-50 dark:bg-amber-950',
label: 'MCP Ready',
title: 'MCP server configured, TaskMaster needs initialization'
};
case 'not-configured':
case 'error':
default:
return {
icon: X,
color: 'text-gray-400 dark:text-gray-500',
bgColor: 'bg-gray-50 dark:bg-gray-900',
label: 'No TaskMaster',
title: 'TaskMaster not configured'
};
}
};
const config = getIndicatorConfig();
const Icon = config.icon;
const sizeClasses = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
};
const paddingClasses = {
xs: 'p-0.5',
sm: 'p-1',
md: 'p-1.5',
lg: 'p-2'
};
if (showLabel) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors',
config.bgColor,
config.color,
className
)}
title={config.title}
>
<Icon className={sizeClasses[size]} />
<span className="font-medium">{config.label}</span>
</div>
);
}
return (
<div
className={cn(
'inline-flex items-center justify-center rounded-full transition-colors',
config.bgColor,
paddingClasses[size],
className
)}
title={config.title}
>
<Icon className={cn(sizeClasses[size], config.color)} />
</div>
);
};
export default TaskIndicator;

1055
src/components/TaskList.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,604 @@
import React, { useState, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { api } from '../utils/api';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskMasterSetupWizard = ({
isOpen = true,
onClose,
onComplete,
currentProject,
className = ''
}) => {
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [setupData, setSetupData] = useState({
projectRoot: '',
initGit: true,
storeTasksInGit: true,
addAliases: true,
skipInstall: false,
rules: ['claude'],
mcpConfigured: false,
prdContent: ''
});
const totalSteps = 4;
useEffect(() => {
if (currentProject) {
setSetupData(prev => ({
...prev,
projectRoot: currentProject.path || ''
}));
}
}, [currentProject]);
const steps = [
{
id: 1,
title: 'Project Configuration',
description: 'Configure basic TaskMaster settings for your project'
},
{
id: 2,
title: 'MCP Server Setup',
description: 'Ensure TaskMaster MCP server is properly configured'
},
{
id: 3,
title: 'PRD Creation',
description: 'Create or import a Product Requirements Document'
},
{
id: 4,
title: 'Complete Setup',
description: 'Initialize TaskMaster and generate initial tasks'
}
];
const handleNext = async () => {
setError(null);
try {
if (currentStep === 1) {
// Validate project configuration
if (!setupData.projectRoot) {
setError('Project root path is required');
return;
}
setCurrentStep(2);
} else if (currentStep === 2) {
// Check MCP server status
setLoading(true);
try {
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
setSetupData(prev => ({
...prev,
mcpConfigured: mcpStatus.hasMCPServer && mcpStatus.isConfigured
}));
setCurrentStep(3);
} catch (err) {
setError('Failed to check MCP server status. You can continue but some features may not work.');
setCurrentStep(3);
}
} else if (currentStep === 3) {
// Validate PRD step
if (!setupData.prdContent.trim()) {
setError('Please create or import a PRD to continue');
return;
}
setCurrentStep(4);
} else if (currentStep === 4) {
// Complete setup
await completeSetup();
}
} catch (err) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
setError(null);
}
};
const completeSetup = async () => {
setLoading(true);
try {
// Initialize TaskMaster project
const initResponse = await api.post('/taskmaster/initialize', {
projectRoot: setupData.projectRoot,
initGit: setupData.initGit,
storeTasksInGit: setupData.storeTasksInGit,
addAliases: setupData.addAliases,
skipInstall: setupData.skipInstall,
rules: setupData.rules,
yes: true
});
if (!initResponse.ok) {
throw new Error('Failed to initialize TaskMaster project');
}
// Save PRD content if provided
if (setupData.prdContent.trim()) {
const prdResponse = await api.post('/taskmaster/save-prd', {
projectRoot: setupData.projectRoot,
content: setupData.prdContent
});
if (!prdResponse.ok) {
console.warn('Failed to save PRD content');
}
}
// Parse PRD to generate initial tasks
if (setupData.prdContent.trim()) {
const parseResponse = await api.post('/taskmaster/parse-prd', {
projectRoot: setupData.projectRoot,
input: '.taskmaster/docs/prd.txt',
numTasks: '10',
research: false,
force: false
});
if (!parseResponse.ok) {
console.warn('Failed to parse PRD and generate tasks');
}
}
onComplete?.();
onClose?.();
} catch (err) {
setError(err.message || 'Failed to complete TaskMaster setup');
} finally {
setLoading(false);
}
};
const copyMCPConfig = () => {
const mcpConfig = `{
"mcpServers": {
"": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
}
}
}
}`;
copyTextToClipboard(mcpConfig);
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<div className="space-y-6">
<div className="text-center">
<Settings className="w-12 h-12 text-blue-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Project Configuration
</h3>
<p className="text-gray-600 dark:text-gray-400">
Configure TaskMaster settings for your project
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Root Path
</label>
<input
type="text"
value={setupData.projectRoot}
onChange={(e) => setSetupData(prev => ({ ...prev, projectRoot: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="/path/to/your/project"
/>
</div>
<div className="space-y-3">
<h4 className="font-medium text-gray-900 dark:text-white">Options</h4>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={setupData.initGit}
onChange={(e) => setSetupData(prev => ({ ...prev, initGit: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Initialize Git repository</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={setupData.storeTasksInGit}
onChange={(e) => setSetupData(prev => ({ ...prev, storeTasksInGit: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Store tasks in Git</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={setupData.addAliases}
onChange={(e) => setSetupData(prev => ({ ...prev, addAliases: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Add shell aliases (tm, taskmaster)</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Rule Profiles
</label>
<div className="grid grid-cols-3 gap-2">
{['claude', 'cursor', 'vscode', 'roo', 'cline', 'windsurf'].map(rule => (
<label key={rule} className="flex items-center gap-2">
<input
type="checkbox"
checked={setupData.rules.includes(rule)}
onChange={(e) => {
if (e.target.checked) {
setSetupData(prev => ({ ...prev, rules: [...prev.rules, rule] }));
} else {
setSetupData(prev => ({ ...prev, rules: prev.rules.filter(r => r !== rule) }));
}
}}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">{rule}</span>
</label>
))}
</div>
</div>
</div>
</div>
);
case 2:
return (
<div className="space-y-6">
<div className="text-center">
<Server className="w-12 h-12 text-purple-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
MCP Server Setup
</h3>
<p className="text-gray-600 dark:text-gray-400">
TaskMaster works best with the MCP server configured
</p>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
MCP Server Configuration
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
To enable full TaskMaster integration, add the MCP server configuration to your Claude settings.
</p>
<div className="bg-white dark:bg-gray-800 rounded border p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-mono text-gray-600 dark:text-gray-400">.mcp.json</span>
<button
onClick={copyMCPConfig}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<Copy className="w-3 h-3" />
Copy
</button>
</div>
<pre className="text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
{`{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
}
}
}
}`}
</pre>
</div>
<div className="flex items-center gap-2 text-sm">
<a
href="https://docs.anthropic.com/en/docs/build-with-claude/tool-use/mcp-servers"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-1"
>
Learn about MCP setup
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Current Status</h4>
<div className="flex items-center gap-2">
{setupData.mcpConfigured ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-green-700 dark:text-green-300">MCP server is configured</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-amber-500" />
<span className="text-sm text-amber-700 dark:text-amber-300">MCP server not detected (optional)</span>
</>
)}
</div>
</div>
</div>
);
case 3:
return (
<div className="space-y-6">
<div className="text-center">
<FileText className="w-12 h-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Product Requirements Document
</h3>
<p className="text-gray-600 dark:text-gray-400">
Create or import a PRD to generate initial tasks
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
PRD Content
</label>
<textarea
value={setupData.prdContent}
onChange={(e) => setSetupData(prev => ({ ...prev, prdContent: e.target.value }))}
rows={12}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
placeholder="# Product Requirements Document
## 1. Overview
Describe your project or feature...
## 2. Objectives
- Primary goal
- Success metrics
## 3. User Stories
- As a user, I want...
## 4. Requirements
- Feature requirements
- Technical requirements
## 5. Implementation Plan
- Phase 1: Core features
- Phase 2: Enhancements"
/>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
AI Task Generation
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200">
TaskMaster will analyze your PRD and automatically generate a structured task list with dependencies, priorities, and implementation details.
</p>
</div>
</div>
</div>
</div>
</div>
);
case 4:
return (
<div className="space-y-6">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Complete Setup
</h3>
<p className="text-gray-600 dark:text-gray-400">
Ready to initialize TaskMaster for your project
</p>
</div>
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 className="font-medium text-green-900 dark:text-green-100 mb-3">
Setup Summary
</h4>
<ul className="space-y-2 text-sm text-green-800 dark:text-green-200">
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Project: {setupData.projectRoot}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Rules: {setupData.rules.join(', ')}
</li>
{setupData.mcpConfigured && (
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
MCP server configured
</li>
)}
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
PRD content ready ({setupData.prdContent.length} characters)
</li>
</ul>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
What happens next?
</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li>Initialize TaskMaster project structure</li>
<li>Save your PRD to <code>.taskmaster/docs/prd.txt</code></li>
<li>Generate initial tasks from your PRD</li>
<li>Set up project configuration and rules</li>
</ol>
</div>
</div>
);
default:
return null;
}
};
if (!isOpen) return null;
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
<div className={cn(
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
className
)}>
{/* Header */}
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3">
<Sparkles className="w-6 h-6 text-blue-600" />
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
TaskMaster Setup Wizard
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
Step {currentStep} of {totalSteps}: {steps[currentStep - 1]?.description}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
title="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Bar */}
<div className="px-4 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors',
currentStep > step.id
? 'bg-green-500 text-white'
: currentStep === step.id
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
)}>
{currentStep > step.id ? (
<CheckCircle className="w-4 h-4" />
) : (
step.id
)}
</div>
{index < steps.length - 1 && (
<div className={cn(
'w-16 h-1 mx-2 rounded',
currentStep > step.id
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
)} />
)}
</div>
))}
</div>
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
{steps.map(step => (
<span key={step.id} className="text-center">
{step.title}
</span>
))}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-6">
{renderStepContent()}
{error && (
<div className="mt-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" />
<div>
<h4 className="font-medium text-red-900 dark:text-red-100 mb-1">Error</h4>
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
onClick={handlePrevious}
disabled={currentStep === 1}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<div className="text-sm text-gray-500 dark:text-gray-400">
{currentStep} of {totalSteps}
</div>
<button
onClick={handleNext}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{currentStep === totalSteps ? 'Setting up...' : 'Processing...'}
</>
) : (
<>
{currentStep === totalSteps ? 'Complete Setup' : 'Next'}
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
};
export default TaskMasterSetupWizard;

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import TaskIndicator from './TaskIndicator';
const TaskMasterStatus = () => {
const {
currentProject,
projectTaskMaster,
mcpServerStatus,
isLoading,
isLoadingMCP,
error
} = useTaskMaster();
if (isLoading || isLoadingMCP) {
return (
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<div className="animate-spin w-3 h-3 border border-gray-300 border-t-blue-500 rounded-full mr-2"></div>
Loading TaskMaster status...
</div>
);
}
if (error) {
return (
<div className="flex items-center text-sm text-red-500 dark:text-red-400">
<span className="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
TaskMaster Error
</div>
);
}
// Show MCP server status
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
// Show project TaskMaster status
const projectConfigured = currentProject?.taskmaster?.hasTaskmaster;
const taskCount = currentProject?.taskmaster?.metadata?.taskCount || 0;
const completedCount = currentProject?.taskmaster?.metadata?.completed || 0;
if (!currentProject) {
return (
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<span className="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
No project selected
</div>
);
}
// Determine overall status for TaskIndicator
let overallStatus = 'not-configured';
if (projectConfigured && mcpConfigured) {
overallStatus = 'fully-configured';
} else if (projectConfigured) {
overallStatus = 'taskmaster-only';
} else if (mcpConfigured) {
overallStatus = 'mcp-only';
}
return (
<div className="flex items-center gap-3">
{/* TaskMaster Status Indicator */}
<TaskIndicator
status={overallStatus}
size="md"
showLabel={true}
/>
{/* Task Progress Info */}
{projectConfigured && (
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
{completedCount}/{taskCount} tasks
</span>
{taskCount > 0 && (
<span className="ml-2 opacity-75">
({Math.round((completedCount / taskCount) * 100)}%)
</span>
)}
</div>
)}
</div>
);
};
export default TaskMasterStatus;

View File

@@ -0,0 +1,3 @@
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
export default TasksSettingsTab;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Badge } from './ui/badge';
import { CheckCircle2, Clock, Circle } from 'lucide-react';
const TodoList = ({ todos, isResult = false }) => {
if (!todos || !Array.isArray(todos)) {
return null;
}
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />;
case 'in_progress':
return <Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />;
case 'pending':
default:
return <Circle className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800';
case 'in_progress':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800';
case 'pending':
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
}
};
const getPriorityColor = (priority) => {
switch (priority) {
case 'high':
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800';
case 'medium':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800';
case 'low':
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
}
};
return (
<div className="space-y-1.5">
{isResult && (
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div>
)}
{todos.map((todo, index) => (
<div
key={todo.id || `todo-${index}`}
className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-0.5">
<p className={`text-xs font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${getPriorityColor(todo.priority)}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${getStatusColor(todo.status)}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default TodoList;

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { cn } from '../lib/utils';
const Tooltip = ({
children,
content,
position = 'top',
className = '',
delay = 500
}) => {
const [isVisible, setIsVisible] = useState(false);
const [timeoutId, setTimeoutId] = useState(null);
const handleMouseEnter = () => {
const id = setTimeout(() => {
setIsVisible(true);
}, delay);
setTimeoutId(id);
};
const handleMouseLeave = () => {
if (timeoutId) {
clearTimeout(timeoutId);
setTimeoutId(null);
}
setIsVisible(false);
};
const getPositionClasses = () => {
switch (position) {
case 'top':
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
case 'bottom':
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
case 'left':
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
case 'right':
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
default:
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
}
};
const getArrowClasses = () => {
switch (position) {
case 'top':
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
case 'bottom':
return 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100';
case 'left':
return 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100';
case 'right':
return 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100';
default:
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
}
};
if (!content) {
return children;
}
return (
<div
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{isVisible && (
<div className={cn(
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
'animate-in fade-in-0 zoom-in-95 duration-200',
getPositionClasses(),
className
)}>
{content}
{/* Arrow */}
<div className={cn(
'absolute w-0 h-0 border-4 border-transparent',
getArrowClasses()
)} />
</div>
)}
</div>
);
};
export default Tooltip;

View File

@@ -1,21 +1,22 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Sidebar from '../sidebar/view/Sidebar';
import MainContent from '../main-content/view/MainContent';
import MobileNav from '../MobileNav';
import { useWebSocket } from '../../contexts/WebSocketContext';
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState';
import MobileNav from './MobileNav';
export default function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams<{ sessionId?: string }>();
const { t } = useTranslation('common');
const { isMobile } = useDeviceSettings({ trackPWA: false });
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
const wasConnectedRef = useRef(false);
const { ws, sendMessage, latestMessage } = useWebSocket();
const {
activeSessions,
@@ -40,7 +41,7 @@ export default function AppContent() {
setIsInputFocused,
setShowSettings,
openSettings,
refreshProjectsSilently,
fetchProjects,
sidebarSharedProps,
} = useProjectsState({
sessionId,
@@ -51,16 +52,14 @@ export default function AppContent() {
});
useEffect(() => {
// Expose a non-blocking refresh for chat/session flows.
// Full loading refreshes are still available through direct fetchProjects calls.
window.refreshProjects = refreshProjectsSilently;
window.refreshProjects = fetchProjects;
return () => {
if (window.refreshProjects === refreshProjectsSilently) {
if (window.refreshProjects === fetchProjects) {
delete window.refreshProjects;
}
};
}, [refreshProjectsSilently]);
}, [fetchProjects]);
useEffect(() => {
window.openSettings = openSettings;
@@ -72,24 +71,6 @@ export default function AppContent() {
};
}, [openSettings]);
// Permission recovery: query pending permissions on WebSocket reconnect or session change
useEffect(() => {
const isReconnect = isConnected && !wasConnectedRef.current;
if (isReconnect) {
wasConnectedRef.current = true;
} else if (!isConnected) {
wasConnectedRef.current = false;
}
if (isConnected && selectedSession?.id) {
sendMessage({
type: 'get-pending-permissions',
sessionId: selectedSession.id
});
}
}, [isConnected, selectedSession?.id, sendMessage]);
return (
<div className="fixed inset-0 flex bg-background">
{!isMobile ? (
@@ -98,7 +79,7 @@ export default function AppContent() {
</div>
) : (
<div
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'visible opacity-100' : 'invisible opacity-0'
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}
>
<button
@@ -115,7 +96,7 @@ export default function AppContent() {
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/>
<div
className={`relative h-full w-[85vw] max-w-sm transform border-r border-border/40 bg-card transition-transform duration-150 ease-out sm:w-80 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border/40 transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
onClick={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
@@ -125,7 +106,7 @@ export default function AppContent() {
</div>
)}
<div className={`flex min-w-0 flex-1 flex-col ${isMobile ? 'pb-mobile-nav' : ''}`}>
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}

View File

@@ -1,179 +0,0 @@
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import {
MessageSquare,
Folder,
Terminal,
GitBranch,
ClipboardCheck,
Ellipsis,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
type CoreNavItem = {
id: CoreTabId;
icon: LucideIcon;
label: string;
};
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
isInputFocused: boolean;
};
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { t } = useTranslation(['common', 'settings']);
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement | null>(null);
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
const isPluginActive = activeTab.startsWith('plugin:');
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e: PointerEvent) => {
const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
setMoreOpen(false);
}
};
document.addEventListener('pointerdown', handleTap);
return () => document.removeEventListener('pointerdown', handleTap);
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name: string) => {
const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
setMoreOpen(false);
};
const baseCoreItems: CoreNavItem[] = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
];
const coreItems: CoreNavItem[] = shouldShowTasksTab
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
: baseCoreItems;
return (
<div
className={`fixed bottom-0 left-0 right-0 z-50 transform px-3 pb-[max(8px,env(safe-area-inset-bottom))] transition-transform duration-300 ease-in-out ${isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`}
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
{coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
setActiveTab(item.id);
}}
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
>
{isActive && (
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)}
<Icon
className={`relative z-10 transition-all duration-200 ${isActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
{item.label}
</span>
</button>
);
})}
{/* "More" button — only shown when there are enabled plugins */}
{hasPlugins && (
<div ref={moreRef} className="relative flex-1">
<button
onClick={() => setMoreOpen((v) => !v)}
onTouchStart={(e) => {
e.preventDefault();
setMoreOpen((v) => !v);
}}
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label="More plugins"
aria-expanded={moreOpen}
>
{(isPluginActive && !moreOpen) && (
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)}
<Ellipsis
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isPluginActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
{t('settings:pluginSettings.morePlugins')}
</span>
</button>
{/* Popover menu */}
{moreOpen && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
{enabledPlugins.map((p) => {
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
const isActive = activeTab === `plugin:${p.name}`;
return (
<button
key={p.name}
onClick={() => selectPlugin(p.name)}
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
? 'bg-primary/8 text-primary'
: 'text-foreground hover:bg-muted/60'
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="truncate">{p.displayName}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,8 +0,0 @@
export const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
export const AUTH_ERROR_MESSAGES = {
authStatusCheckFailed: 'Failed to check authentication status',
loginFailed: 'Login failed',
registrationFailed: 'Registration failed',
networkError: 'Network error. Please try again.',
} as const;

View File

@@ -1,222 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { api } from '../../../utils/api';
import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants';
import type {
AuthContextValue,
AuthProviderProps,
AuthSessionPayload,
AuthStatusPayload,
AuthUser,
AuthUserPayload,
OnboardingStatusPayload,
} from '../types';
import { parseJsonSafely, resolveApiErrorMessage } from '../utils';
const AuthContext = createContext<AuthContextValue | null>(null);
const readStoredToken = (): string | null => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
const persistToken = (token: string) => {
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
};
const clearStoredToken = () => {
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
};
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(() => readStoredToken());
const [isLoading, setIsLoading] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true);
const [error, setError] = useState<string | null>(null);
const setSession = useCallback((nextUser: AuthUser, nextToken: string) => {
setUser(nextUser);
setToken(nextToken);
persistToken(nextToken);
}, []);
const clearSession = useCallback(() => {
setUser(null);
setToken(null);
clearStoredToken();
}, []);
const checkOnboardingStatus = useCallback(async () => {
try {
const response = await api.user.onboardingStatus();
if (!response.ok) {
return;
}
const payload = await parseJsonSafely<OnboardingStatusPayload>(response);
setHasCompletedOnboarding(Boolean(payload?.hasCompletedOnboarding));
} catch (caughtError) {
console.error('Error checking onboarding status:', caughtError);
// Fail open to avoid blocking access on transient onboarding status errors.
setHasCompletedOnboarding(true);
}
}, []);
const refreshOnboardingStatus = useCallback(async () => {
await checkOnboardingStatus();
}, [checkOnboardingStatus]);
const checkAuthStatus = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const statusResponse = await api.auth.status();
const statusPayload = await parseJsonSafely<AuthStatusPayload>(statusResponse);
if (statusPayload?.needsSetup) {
setNeedsSetup(true);
return;
}
setNeedsSetup(false);
if (!token) {
return;
}
const userResponse = await api.auth.user();
if (!userResponse.ok) {
clearSession();
return;
}
const userPayload = await parseJsonSafely<AuthUserPayload>(userResponse);
if (!userPayload?.user) {
clearSession();
return;
}
setUser(userPayload.user);
await checkOnboardingStatus();
} catch (caughtError) {
console.error('[Auth] Auth status check failed:', caughtError);
setError(AUTH_ERROR_MESSAGES.authStatusCheckFailed);
} finally {
setIsLoading(false);
}
}, [checkOnboardingStatus, clearSession, token]);
useEffect(() => {
if (IS_PLATFORM) {
setUser({ username: 'platform-user' });
setNeedsSetup(false);
void checkOnboardingStatus().finally(() => {
setIsLoading(false);
});
return;
}
void checkAuthStatus();
}, [checkAuthStatus, checkOnboardingStatus]);
const login = useCallback<AuthContextValue['login']>(
async (username, password) => {
try {
setError(null);
const response = await api.auth.login(username, password);
const payload = await parseJsonSafely<AuthSessionPayload>(response);
if (!response.ok || !payload?.token || !payload.user) {
const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.loginFailed);
setError(message);
return { success: false, error: message };
}
setSession(payload.user, payload.token);
setNeedsSetup(false);
await checkOnboardingStatus();
return { success: true };
} catch (caughtError) {
console.error('Login error:', caughtError);
setError(AUTH_ERROR_MESSAGES.networkError);
return { success: false, error: AUTH_ERROR_MESSAGES.networkError };
}
},
[checkOnboardingStatus, setSession],
);
const register = useCallback<AuthContextValue['register']>(
async (username, password) => {
try {
setError(null);
const response = await api.auth.register(username, password);
const payload = await parseJsonSafely<AuthSessionPayload>(response);
if (!response.ok || !payload?.token || !payload.user) {
const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.registrationFailed);
setError(message);
return { success: false, error: message };
}
setSession(payload.user, payload.token);
setNeedsSetup(false);
await checkOnboardingStatus();
return { success: true };
} catch (caughtError) {
console.error('Registration error:', caughtError);
setError(AUTH_ERROR_MESSAGES.networkError);
return { success: false, error: AUTH_ERROR_MESSAGES.networkError };
}
},
[checkOnboardingStatus, setSession],
);
const logout = useCallback(() => {
const tokenToInvalidate = token;
clearSession();
if (tokenToInvalidate) {
void api.auth.logout().catch((caughtError: unknown) => {
console.error('Logout endpoint error:', caughtError);
});
}
}, [clearSession, token]);
const contextValue = useMemo<AuthContextValue>(
() => ({
user,
token,
isLoading,
needsSetup,
hasCompletedOnboarding,
error,
login,
register,
logout,
refreshOnboardingStatus,
}),
[
error,
hasCompletedOnboarding,
isLoading,
login,
logout,
needsSetup,
refreshOnboardingStatus,
register,
token,
user,
],
);
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
}

View File

@@ -1,2 +0,0 @@
export { AuthProvider, useAuth } from './context/AuthContext';
export { default as ProtectedRoute } from './view/ProtectedRoute';

View File

@@ -1,50 +0,0 @@
import type { ReactNode } from 'react';
export type AuthUser = {
id?: number | string;
username: string;
[key: string]: unknown;
};
export type AuthActionResult = { success: true } | { success: false; error: string };
export type AuthSessionPayload = {
token?: string;
user?: AuthUser;
error?: string;
message?: string;
};
export type AuthStatusPayload = {
needsSetup?: boolean;
};
export type AuthUserPayload = {
user?: AuthUser;
};
export type OnboardingStatusPayload = {
hasCompletedOnboarding?: boolean;
};
export type ApiErrorPayload = {
error?: string;
message?: string;
};
export type AuthContextValue = {
user: AuthUser | null;
token: string | null;
isLoading: boolean;
needsSetup: boolean;
hasCompletedOnboarding: boolean;
error: string | null;
login: (username: string, password: string) => Promise<AuthActionResult>;
register: (username: string, password: string) => Promise<AuthActionResult>;
logout: () => void;
refreshOnboardingStatus: () => Promise<void>;
};
export type AuthProviderProps = {
children: ReactNode;
};

View File

@@ -1,17 +0,0 @@
import type { ApiErrorPayload } from './types';
export async function parseJsonSafely<T>(response: Response): Promise<T | null> {
try {
return (await response.json()) as T;
} catch {
return null;
}
}
export function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback: string): string {
if (!payload) {
return fallback;
}
return payload.error ?? payload.message ?? fallback;
}

View File

@@ -1,15 +0,0 @@
type AuthErrorAlertProps = {
errorMessage: string;
};
export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
if (!errorMessage) {
return null;
}
return (
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
);
}

View File

@@ -1,37 +0,0 @@
type AuthInputFieldProps = {
id: string;
label: string;
value: string;
onChange: (nextValue: string) => void;
placeholder: string;
isDisabled: boolean;
type?: 'text' | 'password' | 'email';
};
export default function AuthInputField({
id,
label,
value,
onChange,
placeholder,
isDisabled,
type = 'text',
}: AuthInputFieldProps) {
return (
<div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
{label}
</label>
<input
id={id}
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={placeholder}
required
disabled={isDisabled}
/>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { MessageSquare } from 'lucide-react';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
export default function AuthLoadingScreen() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center">
<div className="mb-4 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
</div>
</div>
<h1 className="mb-2 text-2xl font-bold text-foreground">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
{loadingDotAnimationDelays.map((delay) => (
<div
key={delay}
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
style={{ animationDelay: delay }}
/>
))}
</div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
type AuthScreenLayoutProps = {
title: string;
description: string;
children: ReactNode;
footerText: string;
logo?: ReactNode;
};
export default function AuthScreenLayout({
title,
description,
children,
footerText,
logo,
}: AuthScreenLayoutProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="text-center">
<div className="mb-4 flex justify-center">
{logo ?? (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
</div>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="mt-2 text-muted-foreground">{description}</p>
</div>
{children}
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,90 +0,0 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
import AuthScreenLayout from './AuthScreenLayout';
type LoginFormState = {
username: string;
password: string;
};
const initialState: LoginFormState = {
username: '',
password: '',
};
export default function LoginForm() {
const { t } = useTranslation('auth');
const { login } = useAuth();
const [formState, setFormState] = useState<LoginFormState>(initialState);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = useCallback((field: keyof LoginFormState, value: string) => {
setFormState((previous) => ({ ...previous, [field]: value }));
}, []);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage('');
// Keep form validation local so each auth screen owns its own UI feedback.
if (!formState.username.trim() || !formState.password) {
setErrorMessage(t('login.errors.requiredFields'));
return;
}
setIsSubmitting(true);
const result = await login(formState.username.trim(), formState.password);
if (!result.success) {
setErrorMessage(result.error);
}
setIsSubmitting(false);
},
[formState.password, formState.username, login, t],
);
return (
<AuthScreenLayout
title={t('login.title')}
description={t('login.description')}
footerText="Enter your credentials to access Claude Code UI"
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
id="username"
label={t('login.username')}
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
/>
<AuthInputField
id="password"
label={t('login.password')}
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder={t('login.placeholders.password')}
isDisabled={isSubmitting}
type="password"
/>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
>
{isSubmitting ? t('login.loading') : t('login.submit')}
</button>
</form>
</AuthScreenLayout>
);
}

View File

@@ -1,41 +0,0 @@
import type { ReactNode } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { useAuth } from '../context/AuthContext';
import Onboarding from '../../onboarding/view/Onboarding';
import AuthLoadingScreen from './AuthLoadingScreen';
import LoginForm from './LoginForm';
import SetupForm from './SetupForm';
type ProtectedRouteProps = {
children: ReactNode;
};
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (isLoading) {
return <AuthLoadingScreen />;
}
if (IS_PLATFORM) {
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return <>{children}</>;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return <>{children}</>;
}

View File

@@ -1,121 +0,0 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
import AuthScreenLayout from './AuthScreenLayout';
type SetupFormState = {
username: string;
password: string;
confirmPassword: string;
};
const initialState: SetupFormState = {
username: '',
password: '',
confirmPassword: '',
};
function validateSetupForm(formState: SetupFormState): string | null {
if (!formState.username.trim() || !formState.password || !formState.confirmPassword) {
return 'Please fill in all fields.';
}
if (formState.username.trim().length < 3) {
return 'Username must be at least 3 characters long.';
}
if (formState.password.length < 6) {
return 'Password must be at least 6 characters long.';
}
if (formState.password !== formState.confirmPassword) {
return 'Passwords do not match.';
}
return null;
}
export default function SetupForm() {
const { register } = useAuth();
const [formState, setFormState] = useState<SetupFormState>(initialState);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = useCallback((field: keyof SetupFormState, value: string) => {
setFormState((previous) => ({ ...previous, [field]: value }));
}, []);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage('');
const validationError = validateSetupForm(formState);
if (validationError) {
setErrorMessage(validationError);
return;
}
setIsSubmitting(true);
const result = await register(formState.username.trim(), formState.password);
if (!result.success) {
setErrorMessage(result.error);
}
setIsSubmitting(false);
},
[formState, register],
);
return (
<AuthScreenLayout
title="Welcome to Claude Code UI"
description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
id="username"
label="Username"
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder="Enter your username"
isDisabled={isSubmitting}
/>
<AuthInputField
id="password"
label="Password"
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder="Enter your password"
isDisabled={isSubmitting}
type="password"
/>
<AuthInputField
id="confirmPassword"
label="Confirm Password"
value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password"
isDisabled={isSubmitting}
type="password"
/>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
>
{isSubmitting ? 'Setting up...' : 'Create Account'}
</button>
</form>
</AuthScreenLayout>
);
}

View File

@@ -11,7 +11,9 @@ import type {
} from 'react';
import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import type {
@@ -19,10 +21,10 @@ import type {
PendingPermissionRequest,
PermissionMode,
} from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting';
type PendingViewSession = {
sessionId: string | null;
@@ -549,7 +551,7 @@ export function useChatComposerState({
};
setChatMessages((previous) => [...previous, userMessage]);
setIsLoading(true); // Processing banner starts
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app';
interface UseChatProviderStateArgs {

View File

@@ -48,7 +48,6 @@ interface UseChatRealtimeHandlersArgs {
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
onWebSocketReconnect?: () => void;
}
const appendStreamingChunk = (
@@ -114,7 +113,6 @@ export function useChatRealtimeHandlers({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
onWebSocketReconnect,
}: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
@@ -136,10 +134,9 @@ export function useChatRealtimeHandlers({
latestMessage.data && typeof latestMessage.data === 'object'
? (latestMessage.data as Record<string, any>)
: null;
const messageType = String(latestMessage.type);
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
const isGlobalMessage = globalMessageTypes.includes(messageType);
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(String(latestMessage.type));
const lifecycleMessageTypes = new Set([
'claude-complete',
'codex-complete',
@@ -149,7 +146,6 @@ export function useChatRealtimeHandlers({
'cursor-error',
'codex-error',
'gemini-error',
'error',
]);
const isClaudeSystemInit =
@@ -172,12 +168,9 @@ export function useChatRealtimeHandlers({
const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const hasPendingUnboundSession =
Boolean(pendingViewSessionRef.current) && !pendingViewSessionRef.current?.sessionId;
const isSystemInitForView =
systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView);
const isLifecycleMessage = lifecycleMessageTypes.has(messageType);
const isUnscopedError =
!latestMessage.sessionId &&
pendingViewSessionRef.current &&
@@ -208,30 +201,6 @@ export function useChatRealtimeHandlers({
setClaudeStatus(null);
};
const clearPendingViewSession = (resolvedSessionId?: string | null) => {
const pendingSession = pendingViewSessionRef.current;
if (!pendingSession) {
return;
}
// If the in-view request never received a concrete session ID (or this terminal event
// resolves the same pending session), clear it to avoid stale "in-flight" UI state.
if (!pendingSession.sessionId || !resolvedSessionId || pendingSession.sessionId === resolvedSessionId) {
pendingViewSessionRef.current = null;
}
};
const flushStreamingState = () => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const pendingChunk = streamBufferRef.current;
streamBufferRef.current = '';
appendStreamingChunk(setChatMessages, pendingChunk, false);
finalizeStreamingMessage(setChatMessages);
};
const markSessionsAsCompleted = (...sessionIds: Array<string | null | undefined>) => {
const normalizedSessionIds = collectSessionIds(...sessionIds);
normalizedSessionIds.forEach((sessionId) => {
@@ -240,46 +209,25 @@ export function useChatRealtimeHandlers({
});
};
const finalizeLifecycleForCurrentView = (...sessionIds: Array<string | null | undefined>) => {
const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const resolvedSessionIds = collectSessionIds(...sessionIds, pendingSessionId, pendingViewSessionRef.current?.sessionId);
const resolvedPrimarySessionId = resolvedSessionIds[0] || null;
flushStreamingState();
clearLoadingIndicators();
markSessionsAsCompleted(...resolvedSessionIds);
setPendingPermissionRequests([]);
clearPendingViewSession(resolvedPrimarySessionId);
};
if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) {
if (latestMessage.sessionId && isLifecycleMessage && !hasPendingUnboundSession) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
return;
}
if (!isUnscopedError && !hasPendingUnboundSession) {
if (!isUnscopedError) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError && !hasPendingUnboundSession) {
if (!latestMessage.sessionId && !isUnscopedError) {
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
const shouldTreatAsPendingViewLifecycle =
!activeViewSessionId &&
hasPendingUnboundSession &&
latestMessage.sessionId &&
isLifecycleMessage;
if (!shouldTreatAsPendingViewLifecycle) {
if (latestMessage.sessionId && isLifecycleMessage) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
return;
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
return;
}
}
@@ -302,11 +250,6 @@ export function useChatRealtimeHandlers({
}
break;
case 'websocket-reconnected':
// WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
onWebSocketReconnect?.();
break;
case 'token-budget':
if (latestMessage.data) {
setTokenBudget(latestMessage.data);
@@ -602,7 +545,6 @@ export function useChatRealtimeHandlers({
break;
case 'claude-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -662,7 +604,6 @@ export function useChatRealtimeHandlers({
break;
case 'cursor-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -677,7 +618,8 @@ export function useChatRealtimeHandlers({
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
finalizeLifecycleForCurrentView(
clearLoadingIndicators();
markSessionsAsCompleted(
cursorCompletedSessionId,
currentSessionId,
selectedSession?.id,
@@ -699,28 +641,14 @@ export function useChatRealtimeHandlers({
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
const normalizedTextResult = textResult.trim();
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent =
normalizedTextResult
textResult && textResult.trim()
? textResult
: `${last.content || ''}${pendingChunk || ''}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
} else if (normalizedTextResult) {
const lastAssistantText =
last && last.type === 'assistant' && !last.isToolUse
? String(last.content || '').trim()
: '';
// Cursor can emit the same final text through both streaming and result payloads.
// Skip adding a second assistant bubble when the final text is unchanged.
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
if (isDuplicateFinalText) {
return updated;
}
} else if (textResult && textResult.trim()) {
updated.push({
type: resultData.is_error ? 'error' : 'assistant',
content: textResult,
@@ -773,7 +701,8 @@ export function useChatRealtimeHandlers({
const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId;
finalizeLifecycleForCurrentView(
clearLoadingIndicators();
markSessionsAsCompleted(
completedSessionId,
currentSessionId,
selectedSession?.id,
@@ -789,6 +718,7 @@ export function useChatRealtimeHandlers({
if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
setPendingPermissionRequests([]);
break;
}
@@ -906,11 +836,13 @@ export function useChatRealtimeHandlers({
}
if (codexData.type === 'turn_complete') {
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
}
if (codexData.type === 'turn_failed') {
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -929,7 +861,8 @@ export function useChatRealtimeHandlers({
const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId;
finalizeLifecycleForCurrentView(
clearLoadingIndicators();
markSessionsAsCompleted(
codexCompletedSessionId,
codexActualSessionId,
currentSessionId,
@@ -953,7 +886,8 @@ export function useChatRealtimeHandlers({
}
case 'codex-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setIsLoading(false);
setCanAbortSession(false);
setChatMessages((previous) => [
...previous,
{
@@ -1003,7 +937,8 @@ export function useChatRealtimeHandlers({
}
case 'gemini-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setIsLoading(false);
setCanAbortSession(false);
setChatMessages((previous) => [
...previous,
{
@@ -1055,11 +990,13 @@ export function useChatRealtimeHandlers({
const abortSucceeded = latestMessage.success !== false;
if (abortSucceeded) {
finalizeLifecycleForCurrentView(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
clearLoadingIndicators();
markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId');
}
setPendingPermissionRequests([]);
setChatMessages((previous) => [
...previous,
{
@@ -1143,25 +1080,6 @@ export function useChatRealtimeHandlers({
break;
}
case 'pending-permissions-response': {
// Server returned pending permissions for this session
const permSessionId = latestMessage.sessionId;
const isCurrentPermSession =
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
if (permSessionId && !isCurrentPermSession) {
break;
}
const serverRequests = latestMessage.data || [];
setPendingPermissionRequests(serverRequests);
break;
}
case 'error':
// Generic backend failure (e.g., provider process failed before a provider-specific
// completion event was emitted). Treat it as terminal for current view lifecycle.
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
break;
default:
break;
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import { api, authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession } from '../../../types/app';
@@ -82,8 +83,6 @@ export function useChatSessionState({
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
const searchScrollActiveRef = useRef(false);
const isLoadingSessionRef = useRef(false);
const isLoadingMoreRef = useRef(false);
const allMessagesLoadedRef = useRef(false);
@@ -94,7 +93,6 @@ export function useChatSessionState({
const scrollPositionRef = useRef({ height: 0, top: 0 });
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedSessionKeyRef = useRef<string | null>(null);
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
@@ -299,18 +297,11 @@ export function useChatSessionState({
pendingScrollRestoreRef.current = null;
}, [chatMessages.length]);
const prevSessionMessagesLengthRef = useRef(0);
const isInitialLoadRef = useRef(true);
useEffect(() => {
if (!searchScrollActiveRef.current) {
pendingInitialScrollRef.current = true;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
}
pendingInitialScrollRef.current = true;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
prevSessionMessagesLengthRef.current = 0;
isInitialLoadRef.current = true;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
setIsUserScrolledUp(false);
}, [selectedProject?.name, selectedSession?.id]);
@@ -325,11 +316,9 @@ export function useChatSessionState({
}
pendingInitialScrollRef.current = false;
if (!searchScrollActiveRef.current) {
setTimeout(() => {
scrollToBottom();
}, 200);
}
setTimeout(() => {
scrollToBottom();
}, 200);
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
useEffect(() => {
@@ -384,15 +373,6 @@ export function useChatSessionState({
}
}
// Skip loading if session+project+provider hasn't changed
const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`;
if (lastLoadedSessionKeyRef.current === sessionKey) {
setTimeout(() => {
isLoadingSessionRef.current = false;
}, 250);
return;
}
if (provider === 'cursor') {
setCurrentSessionId(selectedSession.id);
sessionStorage.setItem('cursorSessionId', selectedSession.id);
@@ -420,9 +400,6 @@ export function useChatSessionState({
setIsSystemSessionChange(false);
}
}
// Update the last loaded session key
lastLoadedSessionKeyRef.current = sessionKey;
} else {
if (!isSystemSessionChange) {
resetStreamingState();
@@ -440,7 +417,6 @@ export function useChatSessionState({
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
lastLoadedSessionKeyRef.current = null;
}
setTimeout(() => {
@@ -457,7 +433,7 @@ export function useChatSessionState({
pendingViewSessionRef,
resetStreamingState,
selectedProject,
selectedSession?.id, // Only depend on session ID, not the entire object
selectedSession,
sendMessage,
ws,
]);
@@ -508,22 +484,6 @@ export function useChatSessionState({
selectedSession,
]);
// Detect search navigation target from selectedSession object reference change
// This must be a separate effect because the loading effect depends on selectedSession?.id
// which doesn't change when clicking a search result for the already-loaded session
useEffect(() => {
const session = selectedSession as Record<string, unknown> | null;
const targetSnippet = session?.__searchTargetSnippet;
const targetTimestamp = session?.__searchTargetTimestamp;
if (typeof targetSnippet === 'string' && targetSnippet) {
searchScrollActiveRef.current = true;
setSearchTarget({
snippet: targetSnippet,
timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined,
});
}
}, [selectedSession]);
useEffect(() => {
if (selectedSession?.id) {
pendingViewSessionRef.current = null;
@@ -531,22 +491,10 @@ export function useChatSessionState({
}, [pendingViewSessionRef, selectedSession?.id]);
useEffect(() => {
// Only sync sessionMessages to chatMessages when:
// 1. Not currently loading (to avoid overwriting user's just-sent message)
// 2. SessionMessages actually changed (including from non-empty to empty)
// 3. Either it's initial load OR sessionMessages increased (new messages from server)
if (
sessionMessages.length !== prevSessionMessagesLengthRef.current &&
!isLoading
) {
// Only update if this is initial load, sessionMessages grew, or was cleared to empty
if (isInitialLoadRef.current || sessionMessages.length === 0 || sessionMessages.length > prevSessionMessagesLengthRef.current) {
setChatMessages(convertedMessages);
isInitialLoadRef.current = false;
}
prevSessionMessagesLengthRef.current = sessionMessages.length;
if (sessionMessages.length > 0) {
setChatMessages(convertedMessages);
}
}, [convertedMessages, sessionMessages.length, isLoading, setChatMessages]);
}, [convertedMessages, sessionMessages.length]);
useEffect(() => {
if (selectedProject && chatMessages.length > 0) {
@@ -554,110 +502,6 @@ export function useChatSessionState({
}
}, [chatMessages, selectedProject]);
// Scroll to search target message after messages are loaded
useEffect(() => {
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
const target = searchTarget;
// Clear immediately to prevent re-triggering
setSearchTarget(null);
const scrollToTarget = async () => {
// Always load all messages when navigating from search
// (hasMoreMessages may not be set yet due to race with loading effect)
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'cursor') {
try {
const response = await (api.sessionMessages as any)(
selectedProject.name,
selectedSession.id,
null,
0,
sessionProvider,
);
if (response.ok) {
const data = await response.json();
const allMessages = data.messages || data;
setSessionMessages(Array.isArray(allMessages) ? allMessages : []);
setHasMoreMessages(false);
setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0);
messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0;
setVisibleMessageCount(Infinity);
setAllMessagesLoaded(true);
allMessagesLoadedRef.current = true;
// Wait for messages to render after state update
await new Promise(resolve => setTimeout(resolve, 300));
}
} catch {
// Fall through and scroll in current messages
}
}
}
setVisibleMessageCount(Infinity);
// Retry finding the element in the DOM until React finishes rendering all messages
const findAndScroll = (retriesLeft: number) => {
const container = scrollContainerRef.current;
if (!container) return;
let targetElement: Element | null = null;
// Match by snippet text content (most reliable)
if (target.snippet) {
const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim();
// Use a contiguous substring from the snippet (don't filter words, it breaks matching)
const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim();
if (searchPhrase.length >= 10) {
const messageElements = container.querySelectorAll('.chat-message');
for (const el of messageElements) {
const text = (el.textContent || '').toLowerCase();
if (text.includes(searchPhrase)) {
targetElement = el;
break;
}
}
}
}
// Fallback to timestamp matching
if (!targetElement && target.timestamp) {
const targetDate = new Date(target.timestamp).getTime();
const messageElements = container.querySelectorAll('[data-message-timestamp]');
let closestDiff = Infinity;
for (const el of messageElements) {
const ts = el.getAttribute('data-message-timestamp');
if (!ts) continue;
const diff = Math.abs(new Date(ts).getTime() - targetDate);
if (diff < closestDiff) {
closestDiff = diff;
targetElement = el;
}
}
}
if (targetElement) {
targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
targetElement.classList.add('search-highlight-flash');
setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000);
searchScrollActiveRef.current = false;
} else if (retriesLeft > 0) {
setTimeout(() => findAndScroll(retriesLeft - 1), 200);
} else {
searchScrollActiveRef.current = false;
}
};
// Start polling after a short delay to let React begin rendering
setTimeout(() => findAndScroll(15), 150);
};
scrollToTarget();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
setTokenBudget(null);
@@ -713,10 +557,6 @@ export function useChatSessionState({
return;
}
if (searchScrollActiveRef.current) {
return;
}
if (autoScrollToBottom) {
if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 50);

View File

@@ -161,7 +161,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
fileMentionSet.has(part) ? (
<span
key={`mention-${index}`}
className="-ml-0.5 rounded-md bg-blue-200/70 box-decoration-clone px-0.5 text-transparent dark:bg-blue-300/40"
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
>
{part}
</span>

View File

@@ -17,7 +17,7 @@ tools/
│ ├── CollapsibleDisplay.tsx # Expandable tool display (uses children pattern)
│ ├── CollapsibleSection.tsx # <details>/<summary> wrapper
│ ├── ContentRenderers/
│ │ ├── ToolDiffViewer.tsx # File diff viewer (memoized)
│ │ ├── DiffViewer.tsx # File diff viewer (memoized)
│ │ ├── MarkdownContent.tsx # Markdown renderer
│ │ ├── FileListContent.tsx # Comma-separated clickable file list
│ │ ├── TodoListContent.tsx # Todo items with status badges
@@ -82,7 +82,7 @@ Wraps `CollapsibleSection` (`<details>`/`<summary>`) with a `border-l-2` accent
rawContent="..." // Raw JSON string
toolCategory="edit" // Drives border color
>
<ToolDiffViewer {...} /> // Content as children
<DiffViewer {...} /> // Content as children
</CollapsibleDisplay>
```
@@ -217,7 +217,7 @@ interface ToolDisplayConfig {
- **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed
- **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes
- **ToolDiffViewer** memoizes `createDiff()` — expensive diff computation cached
- **DiffViewer** memoizes `createDiff()` — expensive diff computation cached
- **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo`
- Tool results route through `ToolRenderer` (no duplicate rendering paths)
- `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection)

View File

@@ -1,8 +1,8 @@
import React, { memo, useMemo, useCallback } from 'react';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
type DiffLine = {
type: string;
@@ -61,6 +61,20 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
isSubagentContainer,
subagentState
}) => {
// Route subagent containers to dedicated component
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result;
@@ -80,20 +94,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}, [displayConfig, parsedData, onFileOpen]);
// Route subagent containers to dedicated component (after hooks to keep call order stable)
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
// Keep hooks above this guard so hook call order stays stable across renders.
if (!displayConfig) return null;
if (displayConfig.type === 'one-line') {
@@ -141,7 +142,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
case 'diff':
if (createDiff) {
contentComponent = (
<ToolDiffViewer
<DiffViewer
{...contentProps}
createDiff={createDiff}
onFileClick={() => onFileOpen?.(contentProps.filePath)}
@@ -201,7 +202,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
const msg = displayConfig.getMessage?.(parsedData) || 'Success';
contentComponent = (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{msg}

View File

@@ -43,7 +43,7 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return (
<div className={`border-l-2 ${borderColor} my-1 py-0.5 pl-3 ${className}`}>
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}>
<CollapsibleSection
title={title}
toolName={toolName}
@@ -54,10 +54,10 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
{children}
{showRawParameters && rawContent && (
<details className="group/raw relative mt-2">
<summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<details className="relative mt-2 group/raw">
<summary className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 py-0.5">
<svg
className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90"
className="w-2.5 h-2.5 transition-transform duration-150 group-open/raw:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -66,7 +66,7 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
</svg>
raw params
</summary>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400">
<pre className="mt-1 text-[11px] bg-gray-50 dark:bg-gray-900/50 border border-gray-200/40 dark:border-gray-700/40 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-600 dark:text-gray-400 font-mono">
{rawContent}
</pre>
</details>

View File

@@ -23,10 +23,10 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
className = ''
}) => {
return (
<details className={`group/details relative ${className}`} open={open}>
<summary className="flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1">
<details className={`relative group/details ${className}`} open={open}>
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
<svg
className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500"
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -34,24 +34,24 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span>
<span className="font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px] flex-shrink-0">/</span>
)}
{onTitleClick ? (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
className="flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline truncate flex-1 text-left transition-colors"
>
{title}
</button>
) : (
<span className="flex-1 truncate text-gray-600 dark:text-gray-400">
<span className="text-gray-600 dark:text-gray-400 truncate flex-1">
{title}
</span>
)}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
{action && <span className="flex-shrink-0 ml-1">{action}</span>}
</summary>
<div className="mt-1.5 pl-[18px]">
{children}

View File

@@ -23,11 +23,11 @@ export const FileListContent: React.FC<FileListContentProps> = ({
return (
<div>
{title && (
<div className="mb-1 text-[11px] text-gray-500 dark:text-gray-400">
<div className="text-[11px] text-gray-500 dark:text-gray-400 mb-1">
{title}
</div>
)}
<div className="flex max-h-48 flex-wrap gap-x-1 gap-y-0.5 overflow-y-auto">
<div className="flex flex-wrap gap-x-1 gap-y-0.5 max-h-48 overflow-y-auto">
{files.map((file, index) => {
const filePath = typeof file === 'string' ? file : file.path;
const fileName = filePath.split('/').pop() || filePath;
@@ -39,13 +39,13 @@ export const FileListContent: React.FC<FileListContentProps> = ({
<span key={index} className="inline-flex items-center">
<button
onClick={handleClick}
className="font-mono text-[11px] text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
title={filePath}
>
{fileName}
</button>
{index < files.length - 1 && (
<span className="ml-1 text-[10px] text-gray-300 dark:text-gray-600">,</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px] ml-1">,</span>
)}
</span>
);

View File

@@ -33,31 +33,31 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
return (
<div
key={idx}
className="border-gray-150 overflow-hidden rounded-lg border bg-gray-50/50 dark:border-gray-700/50 dark:bg-gray-800/30"
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden"
>
<button
type="button"
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="flex w-full items-start gap-2.5 px-3 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full ${
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${
answerLabels.length > 0
? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
{answerLabels.length > 0 ? (
<svg className="h-2.5 w-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<div className="h-1.5 w-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{q.header && (
<span className="inline-flex items-center rounded border border-blue-100/80 bg-blue-50 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:border-blue-800/40 dark:bg-blue-900/30 dark:text-blue-400">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40">
{q.header}
</span>
)}
@@ -67,22 +67,22 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
</span>
)}
</div>
<div className="mt-0.5 text-xs leading-snug text-gray-600 dark:text-gray-400">
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug">
{q.question}
</div>
{!isExpanded && answerLabels.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1 mt-1.5">
{answerLabels.map((lbl) => {
const isCustom = !q.options.some(o => o.label === lbl);
return (
<span
key={lbl}
className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-1.5 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium"
>
{lbl}
{isCustom && (
<span className="text-[9px] font-normal text-blue-400 dark:text-blue-500">(custom)</span>
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span>
)}
</span>
);
@@ -91,14 +91,14 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
)}
{!isExpanded && skipped && hasAnyAnswer && (
<span className="mt-1 inline-block text-[10px] italic text-gray-400 dark:text-gray-500">
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic">
Skipped
</span>
)}
</div>
<svg
className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-gray-400 transition-transform duration-200 dark:text-gray-500 ${
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
@@ -108,36 +108,36 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
</button>
{isExpanded && (
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
<div className="ml-6.5 space-y-1">
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40">
<div className="space-y-1 ml-6.5">
{q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label);
return (
<div
key={opt.label}
className={`flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-[12px] ${
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${
wasSelected
? 'border border-blue-200/60 bg-blue-50/80 dark:border-blue-800/40 dark:bg-blue-900/20'
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40'
: 'text-gray-400 dark:text-gray-500'
}`}
>
<div className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} flex items-center justify-center border-[1.5px] ${
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${
wasSelected
? 'border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-500'
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{wasSelected && (
<svg className="h-2 w-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="min-w-0 flex-1">
<span className={wasSelected ? 'font-medium text-gray-900 dark:text-gray-100' : ''}>
<div className="flex-1 min-w-0">
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}>
{opt.label}
</span>
{opt.description && (
<span className={`mt-0.5 block text-[11px] ${
<span className={`block text-[11px] mt-0.5 ${
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
}`}>
{opt.description}
@@ -151,22 +151,22 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
<div
key={lbl}
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40"
>
<div className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} flex items-center justify-center border-[1.5px] border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-500`}>
<svg className="h-2 w-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}>
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="min-w-0 flex-1">
<span className="font-medium text-gray-900 dark:text-gray-100">{lbl}</span>
<span className="ml-1 text-[10px] text-blue-500 dark:text-blue-400">(custom)</span>
<div className="flex-1 min-w-0">
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span>
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span>
</div>
</div>
))}
{skipped && hasAnyAnswer && (
<div className="px-2.5 py-1 text-[11px] italic text-gray-400 dark:text-gray-500">
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1">
No answer provided
</div>
)}
@@ -178,7 +178,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
})}
{!hasAnyAnswer && total === 1 && (
<div className="text-[11px] italic text-gray-400 dark:text-gray-500">
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
Skipped
</div>
)}

View File

@@ -39,7 +39,7 @@ function parseTaskContent(content: string): TaskItem[] {
const statusConfig = {
completed: {
icon: (
<svg className="h-3.5 w-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
@@ -48,7 +48,7 @@ const statusConfig = {
},
in_progress: {
icon: (
<svg className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
@@ -57,7 +57,7 @@ const statusConfig = {
},
pending: {
icon: (
<svg className="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={2} />
</svg>
),
@@ -76,7 +76,7 @@ export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) =>
// If we couldn't parse any tasks, fall back to text display
if (tasks.length === 0) {
return (
<pre className="whitespace-pre-wrap font-mono text-[11px] text-gray-600 dark:text-gray-400">
<pre className="text-[11px] font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{content}
</pre>
);
@@ -87,13 +87,13 @@ export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) =>
return (
<div>
<div className="mb-1.5 flex items-center gap-2">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-gray-500 dark:text-gray-400">
{completed}/{total} completed
</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className="flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-green-500 transition-all dark:bg-green-400"
className="h-full bg-green-500 dark:bg-green-400 rounded-full transition-all"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
@@ -104,16 +104,16 @@ export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) =>
return (
<div
key={task.id}
className="group flex items-center gap-1.5 py-0.5"
className="flex items-center gap-1.5 py-0.5 group"
>
<span className="flex-shrink-0">{config.icon}</span>
<span className="flex-shrink-0 font-mono text-[11px] text-gray-400 dark:text-gray-500">
<span className="text-[11px] font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
#{task.id}
</span>
<span className={`flex-1 truncate text-xs ${config.textClass}`}>
<span className={`text-xs truncate flex-1 ${config.textClass}`}>
{task.subject}
</span>
<span className={`flex-shrink-0 rounded border px-1 py-px text-[10px] ${config.badgeClass}`}>
<span className={`text-[10px] px-1 py-px rounded border flex-shrink-0 ${config.badgeClass}`}>
{task.status.replace('_', ' ')}
</span>
</div>

Some files were not shown because too many files have changed in this diff Show More