fix: notification banner would cause refresh of page

This commit is contained in:
viper151
2026-03-06 16:44:50 +01:00
291 changed files with 17430 additions and 9770 deletions

22
.github/workflows/discord-release.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
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

1
.husky/commit-msg Normal file
View File

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

1
.husky/pre-commit Normal file
View File

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

View File

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

View File

@@ -3,6 +3,30 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [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) ## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
### New Features ### New Features

271
README.md
View File

@@ -70,137 +70,53 @@ The fastest way to get started — no local setup required. Get a fully managed,
**[Get started with CloudCLI Cloud](https://cloudcli.ai)** **[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Self-Hosted (Open Source)
#### Prerequisites ### Self-Hosted (Open source)
- [Node.js](https://nodejs.org/) v22 or higher Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
- [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
#### One-click Operation ```
No installation required, direct operation:
```bash
npx @siteboon/claude-code-ui npx @siteboon/claude-code-ui
``` ```
The server will start and be accessible at `http://localhost:3001` (or your configured PORT). Or install **globally** for regular use:
**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 npm install -g @siteboon/claude-code-ui
cloudcli
``` ```
Then start with a simple command: Open `http://localhost:3001` — all your existing sessions are discovered automatically.
```bash Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more
claude-code-ui
```
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again. ---
**To update**: ## Which option is right for you?
```bash
cloudcli update
```
### CLI Usage 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.
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands: | | 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 |
| Command / Option | Short | Description | > Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
|------------------|-------|-------------|
| `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 CloudCLI 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 CloudCLI 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 ## Security & Tools Configuration
@@ -223,114 +139,55 @@ 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. **Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
## TaskMaster AI Integration *(Optional)* ---
## FAQ
CloudCLI 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. <details>
<summary>How is this different from Claude Code Remote Control?</summary>
It provides 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.
- AI-powered task generation from PRDs (Product Requirements Documents)
- Smart task breakdown and dependency management
- Visual task boards and progress tracking
**Setup & Documentation**: Visit the [TaskMaster AI GitHub repository](https://github.com/eyaltoledano/claude-task-master) for installation instructions, configuration guides, and usage examples. 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.
After installing it you should be able to enable it from the Settings
Here's what that means in practice:
## Usage Guide - **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.
### Core Features </details>
#### Project Management <details>
It automatically discovers Claude Code, Cursor or Codex sessions when available and groups them together into projects <summary>Do I need to pay for an AI subscription separately?</summary>
session counts
- **Project Actions** - Rename, delete, and organize projects
- **Smart Navigation** - Quick access to recent projects and sessions
- **MCP support** - Add your own MCP servers through the UI
#### Chat Interface 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.
- **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
#### File Explorer & Editor </details>
- **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
#### Git Explorer <details>
<summary>Can I use CloudCLI UI on my phone?</summary>
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.
#### TaskMaster AI Integration *(Optional)* </details>
- **Visual Task Board** - Kanban-style interface for managing development tasks
- **PRD Parser** - Create Product Requirements Documents and parse them into structured tasks
- **Progress Tracking** - Real-time status updates and completion tracking
#### Session Management <details>
- **Session Persistence** - All conversations automatically saved <summary>Will changes I make in the UI affect my local Claude Code setup?</summary>
- **Session Organization** - Group sessions by project and timestamp
- **Session Actions** - Rename, delete, and export conversation history
- **Cross-device Sync** - Access sessions from any device
### Mobile App 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.
- **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
## Architecture </details>
### System Overview ---
``` ## Community & Support
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Agent │
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
- **File System API** - Exposing file browser for projects
### Frontend (React + Vite)
- **React 18** - Modern component architecture with hooks
- **CodeMirror** - Advanced code editor with syntax highlighting
### Contributing
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 ## License
@@ -351,14 +208,6 @@ This project is open source and free to use, modify, and distribute under the GP
- **[CodeMirror](https://codemirror.net/)** - Advanced code editor - **[CodeMirror](https://codemirror.net/)** - Advanced code editor
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning
## Support & Community
### Stay Updated
- **[Join our Discord](https://discord.gg/buxwujPNRE)** - Get help, share feedback, and connect with the community
- **[CloudCLI Cloud](https://cloudcli.ai)** - Try the hosted cloud version
- **Star** this repository to show support
- **Watch** for updates and new releases
- **Follow** the project for announcements
### Sponsors ### Sponsors
- [Siteboon - AI powered website builder](https://siteboon.ai) - [Siteboon - AI powered website builder](https://siteboon.ai)

3
commitlint.config.js Normal file
View File

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

102
eslint.config.js Normal file
View File

@@ -0,0 +1,102 @@
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> <title>CloudCLI UI</title>
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<!-- iOS Safari PWA Meta Tags --> <!-- iOS Safari PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />

4697
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", "name": "@siteboon/claude-code-ui",
"version": "1.22.0", "version": "1.23.2",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -30,10 +30,13 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"start": "npm run build && npm run server", "start": "npm run build && npm run server",
"release": "./release.sh", "release": "./release.sh",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js" "postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky"
}, },
"keywords": [ "keywords": [
"claude code", "claude code",
@@ -104,6 +107,9 @@
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5", "@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
@@ -112,12 +118,26 @@
"auto-changelog": "^2.5.0", "auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"concurrently": "^8.2.2", "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", "node-gyp": "^10.0.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"release-it": "^19.0.5", "release-it": "^19.0.5",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.0.4" "vite": "^7.0.4"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint"
} }
} }

View File

@@ -35,7 +35,7 @@ function createRequestId() {
} }
function waitForToolApproval(requestId, options = {}) { function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options; const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
return new Promise(resolve => { return new Promise(resolve => {
let settled = false; let settled = false;
@@ -79,9 +79,14 @@ function waitForToolApproval(requestId, options = {}) {
signal.addEventListener('abort', abortHandler, { once: true }); signal.addEventListener('abort', abortHandler, { once: true });
} }
pendingToolApprovals.set(requestId, (decision) => { const resolver = (decision) => {
finalize(decision); finalize(decision);
}); };
// Attach metadata for getPendingApprovalsForSession lookup
if (metadata) {
Object.assign(resolver, metadata);
}
pendingToolApprovals.set(requestId, resolver);
}); });
} }
@@ -210,13 +215,14 @@ function mapCliOptionsToSDK(options = {}) {
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
* @param {string} tempDir - Temp directory for cleanup * @param {string} tempDir - Temp directory for cleanup
*/ */
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) { function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
activeSessions.set(sessionId, { activeSessions.set(sessionId, {
instance: queryInstance, instance: queryInstance,
startTime: Date.now(), startTime: Date.now(),
status: 'active', status: 'active',
tempImagePaths, tempImagePaths,
tempDir tempDir,
writer
}); });
} }
@@ -567,6 +573,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
const decision = await waitForToolApproval(requestId, { const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined, timeoutMs: requiresInteraction ? 0 : undefined,
signal: context?.signal, signal: context?.signal,
metadata: {
_sessionId: capturedSessionId || sessionId || null,
_toolName: toolName,
_input: input,
_receivedAt: new Date(),
},
onCancel: (reason) => { onCancel: (reason) => {
ws.send({ ws.send({
type: 'claude-permission-cancelled', type: 'claude-permission-cancelled',
@@ -629,7 +641,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Track the query instance for abort capability // Track the query instance for abort capability
if (capturedSessionId) { if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
} }
// Process streaming messages // Process streaming messages
@@ -639,7 +651,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
if (message.session_id && !capturedSessionId) { if (message.session_id && !capturedSessionId) {
capturedSessionId = message.session_id; capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
// Set session ID on writer // Set session ID on writer
if (ws.setSessionId && typeof ws.setSessionId === 'function') { if (ws.setSessionId && typeof ws.setSessionId === 'function') {
@@ -788,11 +800,50 @@ function getActiveClaudeSDKSessions() {
return getAllSessions(); 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 public API
export { export {
queryClaudeSDK, queryClaudeSDK,
abortClaudeSDKSession, abortClaudeSDKSession,
isClaudeSDKSessionActive, isClaudeSDKSessionActive,
getActiveClaudeSDKSessions, getActiveClaudeSDKSessions,
resolveToolApproval resolveToolApproval,
getPendingApprovalsForSession,
reconnectSessionWriter
}; };

View File

@@ -121,6 +121,18 @@ const runMigrations = () => {
) )
`); `);
// 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)');
console.log('Database migrations completed successfully'); console.log('Database migrations completed successfully');
} catch (error) { } catch (error) {
console.error('Error running migrations:', error.message); console.error('Error running migrations:', error.message);
@@ -488,6 +500,60 @@ const pushSubscriptionsDb = {
} }
}; };
// 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;
},
};
// 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);
}
}
// Backward compatibility - keep old names pointing to new system // Backward compatibility - keep old names pointing to new system
const githubTokensDb = { const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => { createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -515,5 +581,7 @@ export {
credentialsDb, credentialsDb,
notificationPreferencesDb, notificationPreferencesDb,
pushSubscriptionsDb, pushSubscriptionsDb,
sessionNamesDb,
applyCustomSessionNames,
githubTokensDb // Backward compatibility githubTokensDb // Backward compatibility
}; };

View File

@@ -77,3 +77,16 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
-- 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,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);

View File

@@ -44,8 +44,8 @@ import pty from 'node-pty';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import mime from 'mime-types'; import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js'; import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
@@ -64,11 +64,13 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js'; import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js'; import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js'; import geminiRoutes from './routes/gemini.js';
import { initializeDatabase } from './database/db.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js'; import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js'; import { IS_PLATFORM } from './constants/config.js';
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
// File system watchers for provider project/session folders // File system watchers for provider project/session folders
const PROVIDER_WATCH_PATHS = [ const PROVIDER_WATCH_PATHS = [
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
@@ -494,6 +496,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
try { try {
const { limit = 5, offset = 0 } = req.query; const { limit = 5, offset = 0 } = req.query;
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset)); const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
applyCustomSessionNames(result.sessions, 'claude');
res.json(result); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -542,6 +545,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
const { projectName, sessionId } = req.params; const { projectName, sessionId } = req.params;
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
await deleteSession(projectName, sessionId); await deleteSession(projectName, sessionId);
sessionNamesDb.deleteName(sessionId, 'claude');
console.log(`[API] Session ${sessionId} deleted successfully`); console.log(`[API] Session ${sessionId} deleted successfully`);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
@@ -550,6 +554,32 @@ 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) // Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => { app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try { try {
@@ -579,6 +609,51 @@ 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) => { const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath; if (!inputPath) return inputPath;
if (inputPath === '~') { if (inputPath === '~') {
@@ -1352,6 +1427,10 @@ class WebSocketWriter {
} }
} }
updateWebSocket(newRawWs) {
this.ws = newRawWs;
}
setSessionId(sessionId) { setSessionId(sessionId) {
this.sessionId = sessionId; this.sessionId = sessionId;
} }
@@ -1466,6 +1545,11 @@ function handleChatConnection(ws, request) {
} else { } else {
// Use Claude Agents SDK // Use Claude Agents SDK
isActive = isClaudeSDKSessionActive(sessionId); 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({ writer.send({
@@ -1474,6 +1558,17 @@ function handleChatConnection(ws, request) {
provider, provider,
isProcessing: isActive 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') { } else if (data.type === 'get-active-sessions') {
// Get all currently active sessions // Get all currently active sessions
const activeSessions = { const activeSessions = {
@@ -2114,7 +2209,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
// Allow only safe characters in sessionId // Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId) { if (!safeSessionId || safeSessionId !== String(sessionId)) {
return res.status(400).json({ error: 'Invalid sessionId' }); return res.status(400).json({ error: 'Invalid sessionId' });
} }

View File

@@ -66,6 +66,7 @@ import sqlite3 from 'sqlite3';
import { open } from 'sqlite'; import { open } from 'sqlite';
import os from 'os'; import os from 'os';
import sessionManager from './sessionManager.js'; import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';
// Import TaskMaster detection functions // Import TaskMaster detection functions
async function detectTaskMasterFolder(projectPath) { async function detectTaskMasterFolder(projectPath) {
@@ -458,6 +459,7 @@ async function getProjects(progressCallback = null) {
total: 0 total: 0
}; };
} }
applyCustomSessionNames(project.sessions, 'claude');
// Also fetch Cursor sessions for this project // Also fetch Cursor sessions for this project
try { try {
@@ -466,6 +468,7 @@ async function getProjects(progressCallback = null) {
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
project.cursorSessions = []; project.cursorSessions = [];
} }
applyCustomSessionNames(project.cursorSessions, 'cursor');
// Also fetch Codex sessions for this project // Also fetch Codex sessions for this project
try { try {
@@ -476,14 +479,20 @@ async function getProjects(progressCallback = null) {
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
project.codexSessions = []; project.codexSessions = [];
} }
applyCustomSessionNames(project.codexSessions, 'codex');
// Also fetch Gemini sessions for this project // Also fetch Gemini sessions for this project (UI + CLI)
try { try {
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; 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;
} catch (e) { } catch (e) {
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message); console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
project.geminiSessions = []; project.geminiSessions = [];
} }
applyCustomSessionNames(project.geminiSessions, 'gemini');
// Add TaskMaster detection // Add TaskMaster detection
try { try {
@@ -567,6 +576,7 @@ async function getProjects(progressCallback = null) {
} catch (e) { } catch (e) {
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); 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 to fetch Codex sessions for manual projects too
try { try {
@@ -576,13 +586,18 @@ async function getProjects(progressCallback = null) {
} catch (e) { } catch (e) {
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); 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 // Try to fetch Gemini sessions for manual projects too (UI + CLI)
try { try {
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; 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))];
} catch (e) { } catch (e) {
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message); console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
} }
applyCustomSessionNames(project.geminiSessions, 'gemini');
// Add TaskMaster detection for manual projects // Add TaskMaster detection for manual projects
try { try {
@@ -1071,10 +1086,13 @@ async function renameProject(projectName, newDisplayName) {
if (!newDisplayName || newDisplayName.trim() === '') { if (!newDisplayName || newDisplayName.trim() === '') {
// Remove custom name if empty, will fall back to auto-generated // Remove custom name if empty, will fall back to auto-generated
delete config[projectName]; if (config[projectName]) {
delete config[projectName].displayName;
}
} else { } else {
// Set custom display name // Set custom display name, preserving other properties (manuallyAdded, originalPath)
config[projectName] = { config[projectName] = {
...config[projectName],
displayName: newDisplayName.trim() displayName: newDisplayName.trim()
}; };
} }
@@ -1479,6 +1497,23 @@ 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 // Parse a Codex session JSONL file to extract metadata
async function parseCodexSessionFile(filePath) { async function parseCodexSessionFile(filePath) {
try { try {
@@ -1514,8 +1549,8 @@ async function parseCodexSessionFile(filePath) {
}; };
} }
// Count messages and extract user messages for summary // Count visible user messages and extract summary from the latest plain user input.
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
messageCount++; messageCount++;
if (entry.payload.message) { if (entry.payload.message) {
lastUserMessage = entry.payload.message; lastUserMessage = entry.payload.message;
@@ -1623,24 +1658,35 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
} }
} }
// Extract messages from response_item // Use event_msg.user_message for user-visible inputs.
if (entry.type === 'response_item' && entry.payload?.type === 'message') { if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
const content = entry.payload.content; messages.push({
const role = entry.payload.role || 'assistant'; type: 'user',
const textContent = extractText(content); timestamp: entry.timestamp,
message: {
role: 'user',
content: entry.payload.message
}
});
}
// Skip system context messages (environment_context) // response_item.message may include internal prompts for non-assistant roles.
if (textContent?.includes('<environment_context>')) { // Keep only assistant output from response_item.
continue; if (
} entry.type === 'response_item' &&
entry.payload?.type === 'message' &&
entry.payload.role === 'assistant'
) {
const content = entry.payload.content;
const textContent = extractText(content);
// Only add if there's actual content // Only add if there's actual content
if (textContent?.trim()) { if (textContent?.trim()) {
messages.push({ messages.push({
type: role === 'user' ? 'user' : 'assistant', type: 'assistant',
timestamp: entry.timestamp, timestamp: entry.timestamp,
message: { message: {
role: role, role: 'assistant',
content: textContent content: textContent
} }
}); });
@@ -1823,6 +1869,675 @@ 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 { export {
getProjects, getProjects,
getSessions, getSessions,
@@ -1839,5 +2554,8 @@ export {
clearProjectDirectoryCache, clearProjectDirectoryCache,
getCodexSessions, getCodexSessions,
getCodexSessionMessages, getCodexSessionMessages,
deleteCodexSession deleteCodexSession,
getGeminiCliSessions,
getGeminiCliSessionMessages,
searchConversations
}; };

View File

@@ -14,13 +14,14 @@ router.get('/claude/status', async (req, res) => {
return res.json({ return res.json({
authenticated: true, authenticated: true,
email: credentialsResult.email || 'Authenticated', email: credentialsResult.email || 'Authenticated',
method: 'credentials_file' method: credentialsResult.method // 'api_key' or 'credentials_file'
}); });
} }
return res.json({ return res.json({
authenticated: false, authenticated: false,
email: null, email: null,
method: null,
error: credentialsResult.error || 'Not authenticated' error: credentialsResult.error || 'Not authenticated'
}); });
@@ -29,6 +30,7 @@ router.get('/claude/status', async (req, res) => {
res.status(500).json({ res.status(500).json({
authenticated: false, authenticated: false,
email: null, email: null,
method: null,
error: error.message error: error.message
}); });
} }
@@ -115,6 +117,20 @@ router.get('/gemini/status', async (req, res) => {
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/ */
async function checkClaudeCredentials() { 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 { try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8'); const content = await fs.readFile(credPath, 'utf8');
@@ -127,19 +143,22 @@ async function checkClaudeCredentials() {
if (!isExpired) { if (!isExpired) {
return { return {
authenticated: true, authenticated: true,
email: creds.email || creds.user || null email: creds.email || creds.user || null,
method: 'credentials_file'
}; };
} }
} }
return { return {
authenticated: false, authenticated: false,
email: null email: null,
method: null
}; };
} catch (error) { } catch (error) {
return { return {
authenticated: false, authenticated: false,
email: null email: null,
method: null
}; };
} }
} }

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,8 @@ export const CLAUDE_MODELS = {
*/ */
export const CURSOR_MODELS = { export const CURSOR_MODELS = {
OPTIONS: [ 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: 'gpt-5.2-high', label: 'GPT-5.2 High' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' }, { value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' }, { value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
@@ -47,7 +49,7 @@ export const CURSOR_MODELS = {
{ value: 'grok', label: 'Grok' } { value: 'grok', label: 'Grok' }
], ],
DEFAULT: 'gpt-5' DEFAULT: 'gpt-5-3-codex'
}; };
/** /**
@@ -55,6 +57,8 @@ export const CURSOR_MODELS = {
*/ */
export const CODEX_MODELS = { export const CODEX_MODELS = {
OPTIONS: [ OPTIONS: [
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' }, { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.2', label: 'GPT-5.2' }, { value: 'gpt-5.2', label: 'GPT-5.2' },
@@ -63,7 +67,7 @@ export const CODEX_MODELS = {
{ value: 'o4-mini', label: 'O4-mini' } { value: 'o4-mini', label: 'O4-mini' }
], ],
DEFAULT: 'gpt-5.3-codex' DEFAULT: 'gpt-5.4'
}; };
/** /**

View File

@@ -1,11 +1,10 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext'; import { WebSocketProvider } from './contexts/WebSocketContext';
import ProtectedRoute from './components/ProtectedRoute';
import AppContent from './components/app/AppContent'; import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js'; import i18n from './i18n/config.js';

View File

@@ -1,88 +0,0 @@
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

@@ -1,41 +0,0 @@
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

@@ -1,77 +0,0 @@
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,312 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FileText,
FolderPlus,
Pencil,
Trash2,
Copy,
Download,
RefreshCw
} from 'lucide-react';
import { cn } from '../lib/utils';
/**
* FileContextMenu Component
* Right-click context menu for file/directory operations
*/
const FileContextMenu = ({
children,
item,
onRename,
onDelete,
onNewFile,
onNewFolder,
onRefresh,
onCopyPath,
onDownload,
isLoading = false,
className = ''
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const triggerRef = useRef(null);
const isDirectory = item?.type === 'directory';
const isFile = item?.type === 'file';
const isBackground = !item; // Clicked on empty space
// Handle right-click
const handleContextMenu = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
// Adjust position if menu would go off screen
const menuWidth = 200;
const menuHeight = 300;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > window.innerWidth) {
adjustedX = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
adjustedY = window.innerHeight - menuHeight - 10;
}
setPosition({ x: adjustedX, y: adjustedY });
setIsOpen(true);
}, []);
// Close menu
const closeMenu = useCallback(() => {
setIsOpen(false);
}, []);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
closeMenu();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeMenu();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, closeMenu]);
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
if (!menuItems || menuItems.length === 0) return;
const currentIndex = Array.from(menuItems).findIndex(
(item) => item === document.activeElement
);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
menuItems[nextIndex]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
menuItems[prevIndex]?.focus();
break;
case 'Enter':
case ' ':
if (document.activeElement?.hasAttribute('role', 'menuitem')) {
e.preventDefault();
document.activeElement.click();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Handle action click
const handleAction = (action, ...args) => {
closeMenu();
action?.(...args);
};
// Menu item component
const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => (
<button
role="menuitem"
tabIndex={disabled ? -1 : 0}
disabled={disabled || isLoading}
onClick={() => handleAction(onClick)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
disabled
? 'opacity-50 cursor-not-allowed'
: danger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none'
)}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground font-mono">{shortcut}</span>
)}
</button>
);
// Menu divider
const MenuDivider = () => (
<div className="h-px bg-border my-1 mx-2" />
);
// Build menu items based on context
const renderMenuItems = () => {
if (isFile) {
return (
<>
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
if (isDirectory) {
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.(item.path)}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.(item.path)}
/>
<MenuDivider />
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
// Background context (empty space)
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.('')}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.('')}
/>
<MenuDivider />
<MenuItem
icon={RefreshCw}
label={t('fileTree.context.refresh', 'Refresh')}
onClick={onRefresh}
/>
</>
);
};
return (
<>
{/* Trigger element */}
<div
ref={triggerRef}
onContextMenu={handleContextMenu}
className={cn('contents', className)}
>
{children}
</div>
{/* Context menu portal */}
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 9999
}}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t('fileTree.context.loading', 'Loading...')}
</span>
</div>
) : (
renderMenuItems()
)}
</div>
)}
</>
);
};
export default FileContextMenu;

View File

@@ -1,90 +0,0 @@
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

@@ -1,112 +0,0 @@
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

@@ -1,153 +0,0 @@
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

@@ -1,695 +0,0 @@
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

@@ -1,567 +0,0 @@
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

@@ -1,871 +0,0 @@
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

@@ -1,875 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -1,448 +0,0 @@
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

@@ -1,133 +0,0 @@
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;

View File

@@ -1,210 +0,0 @@
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

@@ -1,407 +0,0 @@
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

@@ -1,108 +0,0 @@
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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,604 +0,0 @@
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

@@ -1,86 +0,0 @@
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

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

View File

@@ -1,91 +0,0 @@
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

@@ -1,91 +0,0 @@
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,22 +1,21 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Sidebar from '../sidebar/view/Sidebar'; import Sidebar from '../sidebar/view/Sidebar';
import MainContent from '../main-content/view/MainContent'; import MainContent from '../main-content/view/MainContent';
import MobileNav from '../MobileNav';
import { useWebSocket } from '../../contexts/WebSocketContext'; import { useWebSocket } from '../../contexts/WebSocketContext';
import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState'; import { useProjectsState } from '../../hooks/useProjectsState';
import MobileNav from './MobileNav';
export default function AppContent() { export default function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { sessionId } = useParams<{ sessionId?: string }>(); const { sessionId } = useParams<{ sessionId?: string }>();
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const { isMobile } = useDeviceSettings({ trackPWA: false }); const { isMobile } = useDeviceSettings({ trackPWA: false });
const { ws, sendMessage, latestMessage } = useWebSocket(); const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
const wasConnectedRef = useRef(false);
const { const {
activeSessions, activeSessions,
@@ -71,6 +70,24 @@ export default function AppContent() {
}; };
}, [openSettings]); }, [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 ( return (
<div className="fixed inset-0 flex bg-background"> <div className="fixed inset-0 flex bg-background">
{!isMobile ? ( {!isMobile ? (
@@ -79,7 +96,7 @@ export default function AppContent() {
</div> </div>
) : ( ) : (
<div <div
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible' className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'visible opacity-100' : 'invisible opacity-0'
}`} }`}
> >
<button <button
@@ -96,7 +113,7 @@ export default function AppContent() {
aria-label={t('versionUpdate.ariaLabels.closeSidebar')} aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/> />
<div <div
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' 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'
}`} }`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()}
@@ -106,7 +123,7 @@ export default function AppContent() {
</div> </div>
)} )}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}> <div className={`flex min-w-0 flex-1 flex-col ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent <MainContent
selectedProject={selectedProject} selectedProject={selectedProject}
selectedSession={selectedSession} selectedSession={selectedSession}

View File

@@ -1,9 +1,15 @@
import React from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react'; import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { Dispatch, SetStateAction } from 'react';
import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { AppTab } from '../../types/app';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) { type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
isInputFocused: boolean;
};
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
@@ -42,12 +48,11 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
return ( return (
<div <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 ${ 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'
isInputFocused ? 'translate-y-full' : 'translate-y-0' }`}
}`}
> >
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30"> <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"> <div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = activeTab === item.id; const isActive = activeTab === item.id;
@@ -60,19 +65,18 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
e.preventDefault(); e.preventDefault();
item.onClick(); 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 ${ 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
isActive ? 'text-primary'
? 'text-primary' : 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground hover:text-foreground' }`}
}`}
aria-label={item.label} aria-label={item.label}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
> >
{isActive && ( {isActive && (
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" /> <div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)} )}
<Icon <Icon
className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`} className={`relative z-10 transition-all duration-200 ${isActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8} strokeWidth={isActive ? 2.4 : 1.8}
/> />
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}> <span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
@@ -86,5 +90,3 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
</div> </div>
); );
} }
export default MobileNav;

View File

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,222 @@
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

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

View File

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,90 @@
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

@@ -0,0 +1,41 @@
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

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

View File

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

View File

@@ -134,9 +134,10 @@ export function useChatRealtimeHandlers({
latestMessage.data && typeof latestMessage.data === 'object' latestMessage.data && typeof latestMessage.data === 'object'
? (latestMessage.data as Record<string, any>) ? (latestMessage.data as Record<string, any>)
: null; : null;
const messageType = String(latestMessage.type);
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created']; const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(String(latestMessage.type)); const isGlobalMessage = globalMessageTypes.includes(messageType);
const lifecycleMessageTypes = new Set([ const lifecycleMessageTypes = new Set([
'claude-complete', 'claude-complete',
'codex-complete', 'codex-complete',
@@ -146,6 +147,7 @@ export function useChatRealtimeHandlers({
'cursor-error', 'cursor-error',
'codex-error', 'codex-error',
'gemini-error', 'gemini-error',
'error',
]); ]);
const isClaudeSystemInit = const isClaudeSystemInit =
@@ -168,9 +170,12 @@ export function useChatRealtimeHandlers({
const activeViewSessionId = const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const hasPendingUnboundSession =
Boolean(pendingViewSessionRef.current) && !pendingViewSessionRef.current?.sessionId;
const isSystemInitForView = const isSystemInitForView =
systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView); const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView);
const isLifecycleMessage = lifecycleMessageTypes.has(messageType);
const isUnscopedError = const isUnscopedError =
!latestMessage.sessionId && !latestMessage.sessionId &&
pendingViewSessionRef.current && pendingViewSessionRef.current &&
@@ -201,6 +206,30 @@ export function useChatRealtimeHandlers({
setClaudeStatus(null); 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 markSessionsAsCompleted = (...sessionIds: Array<string | null | undefined>) => {
const normalizedSessionIds = collectSessionIds(...sessionIds); const normalizedSessionIds = collectSessionIds(...sessionIds);
normalizedSessionIds.forEach((sessionId) => { normalizedSessionIds.forEach((sessionId) => {
@@ -209,25 +238,46 @@ 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 (!shouldBypassSessionFilter) {
if (!activeViewSessionId) { if (!activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { if (latestMessage.sessionId && isLifecycleMessage && !hasPendingUnboundSession) {
handleBackgroundLifecycle(latestMessage.sessionId); handleBackgroundLifecycle(latestMessage.sessionId);
return;
} }
if (!isUnscopedError) { if (!isUnscopedError && !hasPendingUnboundSession) {
return; return;
} }
} }
if (!latestMessage.sessionId && !isUnscopedError) { if (!latestMessage.sessionId && !isUnscopedError && !hasPendingUnboundSession) {
return; return;
} }
if (latestMessage.sessionId !== activeViewSessionId) { if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { const shouldTreatAsPendingViewLifecycle =
handleBackgroundLifecycle(latestMessage.sessionId); !activeViewSessionId &&
hasPendingUnboundSession &&
latestMessage.sessionId &&
isLifecycleMessage;
if (!shouldTreatAsPendingViewLifecycle) {
if (latestMessage.sessionId && isLifecycleMessage) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
return;
} }
return;
} }
} }
@@ -545,6 +595,7 @@ export function useChatRealtimeHandlers({
break; break;
case 'claude-error': case 'claude-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -604,6 +655,7 @@ export function useChatRealtimeHandlers({
break; break;
case 'cursor-error': case 'cursor-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -618,8 +670,7 @@ export function useChatRealtimeHandlers({
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
clearLoadingIndicators(); finalizeLifecycleForCurrentView(
markSessionsAsCompleted(
cursorCompletedSessionId, cursorCompletedSessionId,
currentSessionId, currentSessionId,
selectedSession?.id, selectedSession?.id,
@@ -701,8 +752,7 @@ export function useChatRealtimeHandlers({
const completedSessionId = const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId; latestMessage.sessionId || currentSessionId || pendingSessionId;
clearLoadingIndicators(); finalizeLifecycleForCurrentView(
markSessionsAsCompleted(
completedSessionId, completedSessionId,
currentSessionId, currentSessionId,
selectedSession?.id, selectedSession?.id,
@@ -718,7 +768,6 @@ export function useChatRealtimeHandlers({
if (selectedProject && latestMessage.exitCode === 0) { if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
} }
setPendingPermissionRequests([]);
break; break;
} }
@@ -836,13 +885,11 @@ export function useChatRealtimeHandlers({
} }
if (codexData.type === 'turn_complete') { if (codexData.type === 'turn_complete') {
clearLoadingIndicators(); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
} }
if (codexData.type === 'turn_failed') { if (codexData.type === 'turn_failed') {
clearLoadingIndicators(); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -861,8 +908,7 @@ export function useChatRealtimeHandlers({
const codexCompletedSessionId = const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId; latestMessage.sessionId || currentSessionId || codexPendingSessionId;
clearLoadingIndicators(); finalizeLifecycleForCurrentView(
markSessionsAsCompleted(
codexCompletedSessionId, codexCompletedSessionId,
codexActualSessionId, codexActualSessionId,
currentSessionId, currentSessionId,
@@ -886,8 +932,7 @@ export function useChatRealtimeHandlers({
} }
case 'codex-error': case 'codex-error':
setIsLoading(false); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setCanAbortSession(false);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -937,8 +982,7 @@ export function useChatRealtimeHandlers({
} }
case 'gemini-error': case 'gemini-error':
setIsLoading(false); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setCanAbortSession(false);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -990,13 +1034,11 @@ export function useChatRealtimeHandlers({
const abortSucceeded = latestMessage.success !== false; const abortSucceeded = latestMessage.success !== false;
if (abortSucceeded) { if (abortSucceeded) {
clearLoadingIndicators(); finalizeLifecycleForCurrentView(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) { if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
} }
setPendingPermissionRequests([]);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -1080,6 +1122,25 @@ export function useChatRealtimeHandlers({
break; 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: default:
break; break;
} }

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { api, authenticatedFetch } from '../../../utils/api'; import { api, authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types'; import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
@@ -83,6 +82,8 @@ export function useChatSessionState({
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false); const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null); 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 isLoadingSessionRef = useRef(false);
const isLoadingMoreRef = useRef(false); const isLoadingMoreRef = useRef(false);
const allMessagesLoadedRef = useRef(false); const allMessagesLoadedRef = useRef(false);
@@ -93,6 +94,7 @@ export function useChatSessionState({
const scrollPositionRef = useRef({ height: 0, top: 0 }); const scrollPositionRef = useRef({ height: 0, top: 0 });
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadAllOverlayTimerRef = 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(), []); const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
@@ -297,11 +299,18 @@ export function useChatSessionState({
pendingScrollRestoreRef.current = null; pendingScrollRestoreRef.current = null;
}, [chatMessages.length]); }, [chatMessages.length]);
const prevSessionMessagesLengthRef = useRef(0);
const isInitialLoadRef = useRef(true);
useEffect(() => { useEffect(() => {
pendingInitialScrollRef.current = true; if (!searchScrollActiveRef.current) {
pendingInitialScrollRef.current = true;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
}
topLoadLockRef.current = false; topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null; pendingScrollRestoreRef.current = null;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); prevSessionMessagesLengthRef.current = 0;
isInitialLoadRef.current = true;
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
}, [selectedProject?.name, selectedSession?.id]); }, [selectedProject?.name, selectedSession?.id]);
@@ -316,9 +325,11 @@ export function useChatSessionState({
} }
pendingInitialScrollRef.current = false; pendingInitialScrollRef.current = false;
setTimeout(() => { if (!searchScrollActiveRef.current) {
scrollToBottom(); setTimeout(() => {
}, 200); scrollToBottom();
}, 200);
}
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
useEffect(() => { useEffect(() => {
@@ -373,6 +384,15 @@ 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') { if (provider === 'cursor') {
setCurrentSessionId(selectedSession.id); setCurrentSessionId(selectedSession.id);
sessionStorage.setItem('cursorSessionId', selectedSession.id); sessionStorage.setItem('cursorSessionId', selectedSession.id);
@@ -400,6 +420,9 @@ export function useChatSessionState({
setIsSystemSessionChange(false); setIsSystemSessionChange(false);
} }
} }
// Update the last loaded session key
lastLoadedSessionKeyRef.current = sessionKey;
} else { } else {
if (!isSystemSessionChange) { if (!isSystemSessionChange) {
resetStreamingState(); resetStreamingState();
@@ -417,6 +440,7 @@ export function useChatSessionState({
setHasMoreMessages(false); setHasMoreMessages(false);
setTotalMessages(0); setTotalMessages(0);
setTokenBudget(null); setTokenBudget(null);
lastLoadedSessionKeyRef.current = null;
} }
setTimeout(() => { setTimeout(() => {
@@ -433,7 +457,7 @@ export function useChatSessionState({
pendingViewSessionRef, pendingViewSessionRef,
resetStreamingState, resetStreamingState,
selectedProject, selectedProject,
selectedSession, selectedSession?.id, // Only depend on session ID, not the entire object
sendMessage, sendMessage,
ws, ws,
]); ]);
@@ -484,6 +508,22 @@ export function useChatSessionState({
selectedSession, 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(() => { useEffect(() => {
if (selectedSession?.id) { if (selectedSession?.id) {
pendingViewSessionRef.current = null; pendingViewSessionRef.current = null;
@@ -491,10 +531,22 @@ export function useChatSessionState({
}, [pendingViewSessionRef, selectedSession?.id]); }, [pendingViewSessionRef, selectedSession?.id]);
useEffect(() => { useEffect(() => {
if (sessionMessages.length > 0) { // Only sync sessionMessages to chatMessages when:
setChatMessages(convertedMessages); // 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;
} }
}, [convertedMessages, sessionMessages.length]); }, [convertedMessages, sessionMessages.length, isLoading, setChatMessages]);
useEffect(() => { useEffect(() => {
if (selectedProject && chatMessages.length > 0) { if (selectedProject && chatMessages.length > 0) {
@@ -502,6 +554,110 @@ export function useChatSessionState({
} }
}, [chatMessages, selectedProject]); }, [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(() => { useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
setTokenBudget(null); setTokenBudget(null);
@@ -557,6 +713,10 @@ export function useChatSessionState({
return; return;
} }
if (searchScrollActiveRef.current) {
return;
}
if (autoScrollToBottom) { if (autoScrollToBottom) {
if (!isUserScrolledUp) { if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 50); setTimeout(() => scrollToBottom(), 50);

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default; const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return ( return (
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}> <div className={`border-l-2 ${borderColor} my-1 py-0.5 pl-3 ${className}`}>
<CollapsibleSection <CollapsibleSection
title={title} title={title}
toolName={toolName} toolName={toolName}
@@ -54,10 +54,10 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
{children} {children}
{showRawParameters && rawContent && ( {showRawParameters && rawContent && (
<details className="relative mt-2 group/raw"> <details className="group/raw relative mt-2">
<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"> <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">
<svg <svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/raw:rotate-90" className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -66,7 +66,7 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
</svg> </svg>
raw params raw params
</summary> </summary>
<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"> <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">
{rawContent} {rawContent}
</pre> </pre>
</details> </details>

View File

@@ -23,10 +23,10 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
className = '' className = ''
}) => { }) => {
return ( return (
<details className={`relative group/details ${className}`} open={open}> <details className={`group/details relative ${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"> <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">
<svg <svg
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0" className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
{toolName && ( {toolName && (
<span className="font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">{toolName}</span> <span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span>
)} )}
{toolName && ( {toolName && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] flex-shrink-0">/</span> <span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span>
)} )}
{onTitleClick ? ( {onTitleClick ? (
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
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" 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"
> >
{title} {title}
</button> </button>
) : ( ) : (
<span className="text-gray-600 dark:text-gray-400 truncate flex-1"> <span className="flex-1 truncate text-gray-600 dark:text-gray-400">
{title} {title}
</span> </span>
)} )}
{action && <span className="flex-shrink-0 ml-1">{action}</span>} {action && <span className="ml-1 flex-shrink-0">{action}</span>}
</summary> </summary>
<div className="mt-1.5 pl-[18px]"> <div className="mt-1.5 pl-[18px]">
{children} {children}

View File

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

View File

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

View File

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

View File

@@ -22,10 +22,11 @@ export const TextContent: React.FC<TextContentProps> = ({
formattedJson = JSON.stringify(parsed, null, 2); formattedJson = JSON.stringify(parsed, null, 2);
} catch (e) { } catch (e) {
// If parsing fails, use original content // If parsing fails, use original content
console.warn('Failed to parse JSON content:', e);
} }
return ( return (
<pre className={`mt-1 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-2.5 rounded overflow-x-auto font-mono ${className}`}> <pre className={`mt-1 overflow-x-auto rounded bg-gray-900 p-2.5 font-mono text-xs text-gray-100 dark:bg-gray-950 ${className}`}>
{formattedJson} {formattedJson}
</pre> </pre>
); );
@@ -33,7 +34,7 @@ export const TextContent: React.FC<TextContentProps> = ({
if (format === 'code') { if (format === 'code') {
return ( return (
<pre className={`mt-1 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}> <pre className={`mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/50 bg-gray-50 p-2 font-mono text-xs text-gray-700 dark:border-gray-700/50 dark:bg-gray-800/50 dark:text-gray-300 ${className}`}>
{content} {content}
</pre> </pre>
); );
@@ -41,7 +42,7 @@ export const TextContent: React.FC<TextContentProps> = ({
// Plain text // Plain text
return ( return (
<div className={`mt-1 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}> <div className={`mt-1 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 ${className}`}>
{content} {content}
</div> </div>
); );

View File

@@ -0,0 +1,152 @@
import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react';
import { Badge } from '../../../../../shared/view/ui';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
export type TodoItem = {
id?: string;
content: string;
status: string;
priority?: string;
};
type NormalizedTodoItem = {
id?: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
return 'pending';
};
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800">
<div className="mt-0.5 flex-shrink-0">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start justify-between gap-2">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex flex-shrink-0 gap-1">
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo(
({
todos,
isResult = false,
}: {
todos: TodoItem[];
isResult?: boolean;
}) => {
// Memoize normalization to avoid recomputing list metadata on every render.
const normalizedTodos = useMemo<NormalizedTodoItem[]>(
() =>
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
);
if (normalizedTodos.length === 0) {
return null;
}
return (
<div className="space-y-1.5">
{isResult && (
<div className="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Todo List ({normalizedTodos.length}{' '}
{normalizedTodos.length === 1 ? 'item' : 'items'})
</div>
)}
{normalizedTodos.map((todo, index) => (
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} />
))}
</div>
);
}
);
export default TodoList;

View File

@@ -1,23 +1,40 @@
import React from 'react'; import { memo, useMemo } from 'react';
import TodoList from '../../../../TodoList'; import TodoList, { type TodoItem } from './TodoList';
interface TodoListContentProps { const isTodoItem = (value: unknown): value is TodoItem => {
todos: Array<{ if (typeof value !== 'object' || value === null) {
id?: string; return false;
content: string; }
status: string;
priority?: string; const todo = value as Record<string, unknown>;
}>; return typeof todo.content === 'string' && typeof todo.status === 'string';
isResult?: boolean; };
}
/** /**
* Renders a todo list * Renders a todo list
* Used by: TodoWrite, TodoRead * Used by: TodoWrite, TodoRead
*/ */
export const TodoListContent: React.FC<TodoListContentProps> = ({ export const TodoListContent = memo(
todos, ({
isResult = false todos,
}) => { isResult = false,
return <TodoList todos={todos} isResult={isResult} />; }: {
}; todos: unknown;
isResult?: boolean;
}) => {
const safeTodos = useMemo<TodoItem[]>(() => {
if (!Array.isArray(todos)) {
return [];
}
// Tool payloads are runtime data; render only validated todo objects.
return todos.filter(isTodoItem);
}, [todos]);
if (safeTodos.length === 0) {
return null;
}
return <TodoList todos={safeTodos} isResult={isResult} />;
}
);

View File

@@ -148,32 +148,32 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
tabIndex={-1} tabIndex={-1}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`w-full outline-none transition-all duration-500 ease-out ${ className={`w-full outline-none transition-all duration-500 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3' mounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'
}`} }`}
> >
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl"> <div className="relative overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-lg dark:border-gray-700/50 dark:bg-gray-800/90 dark:shadow-2xl">
{/* Accent line */} {/* Accent line */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" /> <div className="absolute left-0 right-0 top-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
{/* Header + Question — compact */} {/* Header + Question — compact */}
<div className="px-4 pt-3.5 pb-2"> <div className="px-4 pb-2 pt-3.5">
<div className="flex items-center gap-2.5 mb-1.5"> <div className="mb-1.5 flex items-center gap-2.5">
{/* Question icon */} {/* Question icon */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center"> <div className="flex h-6 w-6 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15">
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor"> <svg className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
</svg> </svg>
</div> </div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" /> <div className="absolute -right-0.5 -top-0.5 h-2 w-2 animate-pulse rounded-full bg-cyan-400 dark:bg-cyan-500" />
</div> </div>
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500"> <span className="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500">
Claude needs your input Claude needs your input
</span> </span>
{q.header && ( {q.header && (
<span className="inline-flex items-center px-1.5 py-px 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 dark:border-blue-800/50"> <span className="inline-flex items-center rounded border border-blue-100 bg-blue-50 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:border-blue-800/50 dark:bg-blue-900/30 dark:text-blue-400">
{q.header} {q.header}
</span> </span>
)} )}
@@ -181,7 +181,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
{/* Step counter */} {/* Step counter */}
{!isSingle && ( {!isSingle && (
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0"> <span className="flex-shrink-0 text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
{currentStep + 1}/{total} {currentStep + 1}/{total}
</span> </span>
)} )}
@@ -189,7 +189,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
{/* Progress dots (multi-question) */} {/* Progress dots (multi-question) */}
{!isSingle && ( {!isSingle && (
<div className="flex items-center gap-1 mb-2"> <div className="mb-2 flex items-center gap-1">
{questions.map((_, i) => ( {questions.map((_, i) => (
<button <button
key={i} key={i}
@@ -208,7 +208,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
)} )}
{/* Question text */} {/* Question text */}
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100"> <p className="text-[14px] font-medium leading-snug text-gray-900 dark:text-gray-100">
{q.question} {q.question}
</p> </p>
{multi && ( {multi && (
@@ -217,7 +217,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
</div> </div>
{/* Options — tight spacing */} {/* Options — tight spacing */}
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}> <div className="scrollbar-thin max-h-48 overflow-y-auto px-4 pb-2" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
<div className="space-y-1"> <div className="space-y-1">
{q.options.map((opt, optIdx) => { {q.options.map((opt, optIdx) => {
const isSelected = selected.has(opt.label); const isSelected = selected.has(opt.label);
@@ -226,25 +226,25 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
key={opt.label} key={opt.label}
type="button" type="button"
onClick={() => toggleOption(currentStep, opt.label, multi)} onClick={() => toggleOption(currentStep, opt.label, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isSelected isSelected
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30' ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50' : 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
}`} }`}
> >
{/* Keyboard hint */} {/* Keyboard hint */}
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${ <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
isSelected isSelected
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold' ? 'bg-blue-500 font-semibold text-white dark:bg-blue-500'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600' : 'border border-gray-200 bg-gray-100 text-gray-400 group-hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500 dark:group-hover:border-gray-600'
}`}> }`}>
{optIdx + 1} {optIdx + 1}
</kbd> </kbd>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className={`text-[13px] leading-tight transition-colors duration-150 ${ <div className={`text-[13px] leading-tight transition-colors duration-150 ${
isSelected isSelected
? 'text-gray-900 dark:text-gray-100 font-medium' ? 'font-medium text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
}`}> }`}>
{opt.label} {opt.label}
@@ -262,7 +262,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
{/* Selection check */} {/* Selection check */}
{isSelected && ( {isSelected && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> <svg className="h-4 w-4 flex-shrink-0 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
)} )}
@@ -274,28 +274,28 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
<button <button
type="button" type="button"
onClick={() => toggleOther(currentStep, multi)} onClick={() => toggleOther(currentStep, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isOtherOn isOtherOn
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30' ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50' : 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
}`} }`}
> >
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${ <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
isOtherOn isOtherOn
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold' ? 'bg-blue-500 font-semibold text-white dark:bg-blue-500'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600' : 'border border-gray-200 bg-gray-100 text-gray-400 group-hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500 dark:group-hover:border-gray-600'
}`}> }`}>
0 0
</kbd> </kbd>
<span className={`text-[13px] leading-tight transition-colors ${ <span className={`text-[13px] leading-tight transition-colors ${
isOtherOn isOtherOn
? 'text-gray-900 dark:text-gray-100 font-medium' ? 'font-medium text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400'
}`}> }`}>
Other... Other...
</span> </span>
{isOtherOn && ( {isOtherOn && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> <svg className="ml-auto h-4 w-4 flex-shrink-0 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
)} )}
@@ -320,9 +320,9 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
e.stopPropagation(); e.stopPropagation();
}} }}
placeholder="Type your answer..." placeholder="Type your answer..."
className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200" className="w-full rounded-lg border-0 bg-gray-50 px-3 py-1.5 text-[13px] text-gray-900 outline-none ring-1 ring-gray-200 transition-shadow duration-200 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-400 dark:bg-gray-900/60 dark:text-gray-100 dark:ring-gray-700 dark:placeholder:text-gray-600 dark:focus:ring-blue-500"
/> />
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700"> <kbd className="absolute right-2 top-1/2 -translate-y-1/2 rounded border border-gray-200 bg-gray-100 px-1 py-0.5 font-mono text-[9px] text-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-600">
Enter Enter
</kbd> </kbd>
</div> </div>
@@ -332,11 +332,11 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
</div> </div>
{/* Footer — compact */} {/* Footer — compact */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2 border-t border-gray-100 bg-gray-50/50 px-4 py-2 dark:border-gray-700/50 dark:bg-gray-800/50">
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="text-[11px] text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
> >
{isSingle ? 'Skip' : 'Skip all'} {isSingle ? 'Skip' : 'Skip all'}
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span> <span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
@@ -347,9 +347,9 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
<button <button
type="button" type="button"
onClick={() => setCurrentStep(s => s - 1)} onClick={() => setCurrentStep(s => s - 1)}
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150" className="inline-flex items-center gap-0.5 rounded-lg px-2.5 py-1.5 text-[11px] font-medium text-gray-600 transition-all duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700/60"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
Back Back
@@ -361,19 +361,19 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length} disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200" className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-3.5 py-1.5 text-[11px] font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-30 disabled:shadow-none dark:from-blue-500 dark:to-blue-600"
> >
Submit Submit
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span> <span className="ml-0.5 font-mono text-[9px] opacity-70">Enter</span>
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => setCurrentStep(s => s + 1)} onClick={() => setCurrentStep(s => s + 1)}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200" className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-3.5 py-1.5 text-[11px] font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-md dark:from-blue-500 dark:to-blue-600"
> >
Next Next
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span> <span className="ml-0.5 font-mono text-[9px] opacity-70">Enter</span>
</button> </button>
)} )}
</div> </div>

View File

@@ -68,16 +68,16 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const renderCopyButton = () => ( const renderCopyButton = () => (
<button <button
onClick={handleAction} onClick={handleAction}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-all ml-1 flex-shrink-0" className="ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200"
title="Copy to clipboard" title="Copy to clipboard"
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
> >
{copied ? ( {copied ? (
<svg className="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
) : ( ) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg> </svg>
)} )}
@@ -89,15 +89,15 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
return ( return (
<div className="group my-1"> <div className="group my-1">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex items-center gap-1.5 flex-shrink-0 pt-0.5"> <div className="flex flex-shrink-0 items-center gap-1.5 pt-0.5">
<svg className="w-3 h-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0 flex items-start gap-2"> <div className="flex min-w-0 flex-1 items-start gap-2">
<div className="bg-gray-900 dark:bg-black rounded px-2.5 py-1 flex-1 min-w-0"> <div className="min-w-0 flex-1 rounded bg-gray-900 px-2.5 py-1 dark:bg-black">
<code className={`text-xs text-green-400 font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}> <code className={`font-mono text-xs text-green-400 ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}>
<span className="text-green-600 dark:text-green-500 select-none">$ </span>{value} <span className="select-none text-green-600 dark:text-green-500">$ </span>{value}
</code> </code>
</div> </div>
{action === 'copy' && renderCopyButton()} {action === 'copy' && renderCopyButton()}
@@ -105,7 +105,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
</div> </div>
{secondary && ( {secondary && (
<div className="ml-7 mt-1"> <div className="ml-7 mt-1">
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic"> <span className="text-[11px] italic text-gray-400 dark:text-gray-500">
{secondary} {secondary}
</span> </span>
</div> </div>
@@ -118,12 +118,12 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
if (action === 'open-file') { if (action === 'open-file') {
const displayName = value.split('/').pop() || value; const displayName = value.split('/').pop() || value;
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span> <span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<button <button
onClick={handleAction} onClick={handleAction}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline transition-colors truncate" className="truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
title={value} title={value}
> >
{displayName} {displayName}
@@ -135,23 +135,23 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
// Search / jump-to-results style // Search / jump-to-results style
if (action === 'jump-to-results') { if (action === 'jump-to-results') {
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span> <span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className={`text-xs font-mono truncate flex-1 min-w-0 ${colorScheme.primary}`}> <span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>
{value} {value}
</span> </span>
{secondary && ( {secondary && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic flex-shrink-0"> <span className="flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500">
{secondary} {secondary}
</span> </span>
)} )}
{toolResult && ( {toolResult && (
<a <a
href={`#tool-result-${toolId}`} href={`#tool-result-${toolId}`}
className="flex-shrink-0 text-[11px] text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors flex items-center gap-0.5" className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</a> </a>
@@ -162,21 +162,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
// Default one-line style // Default one-line style
return ( return (
<div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}> <div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
{icon && icon !== 'terminal' && ( {icon && icon !== 'terminal' && (
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span> <span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)} )}
{!icon && (label || toolName) && ( {!icon && (label || toolName) && (
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
)} )}
{(icon || label || toolName) && ( {(icon || label || toolName) && (
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span> <span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
)} )}
<span className={`text-xs font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} flex-1 min-w-0 ${colorScheme.primary}`}> <span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>
{value} {value}
</span> </span>
{secondary && ( {secondary && (
<span className={`text-[11px] ${colorScheme.secondary} italic flex-shrink-0`}> <span className={`text-[11px] ${colorScheme.secondary} flex-shrink-0 italic`}>
{secondary} {secondary}
</span> </span>
)} )}

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import type { SubagentChildTool } from '../../types/types'; import type { SubagentChildTool } from '../../types/types';
import { CollapsibleSection } from './CollapsibleSection';
interface SubagentContainerProps { interface SubagentContainerProps {
toolInput: unknown; toolInput: unknown;
@@ -57,7 +57,7 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
const title = `Subagent / ${subagentType}: ${description}`; const title = `Subagent / ${subagentType}: ${description}`;
return ( return (
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1"> <div className="my-1 border-l-2 border-l-purple-500 py-0.5 pl-3 dark:border-l-purple-400">
<CollapsibleSection <CollapsibleSection
title={title} title={title}
toolName="Task" toolName="Task"
@@ -65,21 +65,21 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
> >
{/* Prompt/request to the subagent */} {/* Prompt/request to the subagent */}
{prompt && ( {prompt && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4"> <div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400">
{prompt} {prompt}
</div> </div>
)} )}
{/* Current tool indicator (while running) */} {/* Current tool indicator (while running) */}
{currentTool && !isComplete && ( {currentTool && !isComplete && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" /> <span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span> <span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span> <span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && ( {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<> <>
<span className="text-gray-300 dark:text-gray-600">/</span> <span className="text-gray-300 dark:text-gray-600">/</span>
<span className="font-mono truncate text-gray-500 dark:text-gray-400"> <span className="truncate font-mono text-gray-500 dark:text-gray-400">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)} {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span> </span>
</> </>
@@ -89,8 +89,8 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Completion status */} {/* Completion status */}
{isComplete && ( {isComplete && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1"> <div className="mt-1 flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span> <span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
@@ -99,10 +99,10 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Tool history (collapsed) */} {/* Tool history (collapsed) */}
{childTools.length > 0 && ( {childTools.length > 0 && (
<details className="mt-2 group/history"> <details className="group/history mt-2">
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1"> <summary className="flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg <svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0" className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -111,18 +111,18 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
</svg> </svg>
<span>View tool history ({childTools.length})</span> <span>View tool history ({childTools.length})</span>
</summary> </summary>
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5"> <div className="mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700">
{childTools.map((child, index) => ( {childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400"> <div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span> <span className="w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span> <span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && ( {getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="font-mono truncate text-gray-400 dark:text-gray-500"> <span className="truncate font-mono text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)} {getCompactToolDisplay(child.toolName, child.toolInput)}
</span> </span>
)} )}
{child.toolResult?.isError && ( {child.toolResult?.isError && (
<span className="text-red-500 flex-shrink-0">(error)</span> <span className="flex-shrink-0 text-red-500">(error)</span>
)} )}
</div> </div>
))} ))}
@@ -163,11 +163,11 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
} }
return typeof content === 'string' ? ( return typeof content === 'string' ? (
<div className="whitespace-pre-wrap break-words line-clamp-6"> <div className="line-clamp-6 whitespace-pre-wrap break-words">
{content} {content}
</div> </div>
) : content ? ( ) : content ? (
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]"> <pre className="line-clamp-6 whitespace-pre-wrap break-words font-mono text-[11px]">
{JSON.stringify(content, null, 2)} {JSON.stringify(content, null, 2)}
</pre> </pre>
) : null; ) : null;

View File

@@ -6,7 +6,7 @@ type DiffLine = {
lineNum: number; lineNum: number;
}; };
interface DiffViewerProps { interface ToolDiffViewerProps {
oldContent: string; oldContent: string;
newContent: string; newContent: string;
filePath: string; filePath: string;
@@ -19,7 +19,7 @@ interface DiffViewerProps {
/** /**
* Compact diff viewer VS Code-style * Compact diff viewer VS Code-style
*/ */
export const DiffViewer: React.FC<DiffViewerProps> = ({ export const ToolDiffViewer: React.FC<ToolDiffViewerProps> = ({
oldContent, oldContent,
newContent, newContent,
filePath, filePath,
@@ -38,44 +38,44 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
); );
return ( return (
<div className="border border-gray-200/60 dark:border-gray-700/50 rounded overflow-hidden"> <div className="overflow-hidden rounded border border-gray-200/60 dark:border-gray-700/50">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-2.5 py-1 bg-gray-50/80 dark:bg-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/50"> <div className="flex items-center justify-between border-b border-gray-200/60 bg-gray-50/80 px-2.5 py-1 dark:border-gray-700/50 dark:bg-gray-800/40">
{onFileClick ? ( {onFileClick ? (
<button <button
onClick={onFileClick} onClick={onFileClick}
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer transition-colors" className="cursor-pointer truncate font-mono text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
> >
{filePath} {filePath}
</button> </button>
) : ( ) : (
<span className="text-[11px] font-mono text-gray-600 dark:text-gray-400 truncate"> <span className="truncate font-mono text-[11px] text-gray-600 dark:text-gray-400">
{filePath} {filePath}
</span> </span>
)} )}
<span className={`text-[10px] font-medium px-1.5 py-px rounded ${badgeClasses} flex-shrink-0 ml-2`}> <span className={`rounded px-1.5 py-px text-[10px] font-medium ${badgeClasses} ml-2 flex-shrink-0`}>
{badge} {badge}
</span> </span>
</div> </div>
{/* Diff lines */} {/* Diff lines */}
<div className="text-[11px] font-mono leading-[18px]"> <div className="font-mono text-[11px] leading-[18px]">
{diffLines.map((diffLine, i) => ( {diffLines.map((diffLine, i) => (
<div key={i} className="flex"> <div key={i} className="flex">
<span <span
className={`w-6 text-center select-none flex-shrink-0 ${ className={`w-6 flex-shrink-0 select-none text-center ${
diffLine.type === 'removed' diffLine.type === 'removed'
? 'bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500' ? 'bg-red-50 text-red-400 dark:bg-red-950/30 dark:text-red-500'
: 'bg-green-50 dark:bg-green-950/30 text-green-400 dark:text-green-500' : 'bg-green-50 text-green-400 dark:bg-green-950/30 dark:text-green-500'
}`} }`}
> >
{diffLine.type === 'removed' ? '-' : '+'} {diffLine.type === 'removed' ? '-' : '+'}
</span> </span>
<span <span
className={`px-2 flex-1 whitespace-pre-wrap ${ className={`flex-1 whitespace-pre-wrap px-2 ${
diffLine.type === 'removed' diffLine.type === 'removed'
? 'bg-red-50/50 dark:bg-red-950/20 text-red-800 dark:text-red-200' ? 'bg-red-50/50 text-red-800 dark:bg-red-950/20 dark:text-red-200'
: 'bg-green-50/50 dark:bg-green-950/20 text-green-800 dark:text-green-200' : 'bg-green-50/50 text-green-800 dark:bg-green-950/20 dark:text-green-200'
}`} }`}
> >
{diffLine.content} {diffLine.content}

View File

@@ -1,5 +1,5 @@
export { CollapsibleSection } from './CollapsibleSection'; export { CollapsibleSection } from './CollapsibleSection';
export { DiffViewer } from './DiffViewer'; export { ToolDiffViewer } from './ToolDiffViewer';
export { OneLineDisplay } from './OneLineDisplay'; export { OneLineDisplay } from './OneLineDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay'; export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer'; export { SubagentContainer } from './SubagentContainer';

View File

@@ -274,6 +274,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
} }
return { todos, isResult: true }; return { todos, isResult: true };
} catch (e) { } catch (e) {
console.warn('Failed to parse todo list content:', e);
return { todos: [], isResult: true }; return { todos: [], isResult: true };
} }
} }
@@ -514,6 +515,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
}; };
} catch (e) { } catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' }; return { content: '' };
} }
} }
@@ -544,6 +546,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
}; };
} catch (e) { } catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' }; return { content: '' };
} }
} }

View File

@@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import QuickSettingsPanel from '../../QuickSettingsPanel';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import ChatComposer from './subcomponents/ChatComposer'; import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps } from '../types/types'; import type { ChatInterfaceProps, Provider } from '../types/types';
import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState'; import { useChatComposerState } from '../hooks/useChatComposerState';
import type { Provider } from '../types/types'; import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null; sessionId: string | null;
@@ -87,7 +87,6 @@ function ChatInterface({
isLoadingMoreMessages, isLoadingMoreMessages,
hasMoreMessages, hasMoreMessages,
totalMessages, totalMessages,
isSystemSessionChange,
setIsSystemSessionChange, setIsSystemSessionChange,
canAbortSession, canAbortSession,
setCanAbortSession, setCanAbortSession,
@@ -259,7 +258,7 @@ function ChatInterface({
: t('messageTypes.claude'); : t('messageTypes.claude');
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<p className="text-sm"> <p className="text-sm">
{t('projectSelection.startChatWithProvider', { {t('projectSelection.startChatWithProvider', {
@@ -274,7 +273,7 @@ function ChatInterface({
return ( return (
<> <>
<div className="h-full flex flex-col"> <div className="flex h-full flex-col">
<ChatMessagesPane <ChatMessagesPane
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
onWheel={handleScroll} onWheel={handleScroll}

View File

@@ -1,6 +1,5 @@
import { SessionProvider } from '../../../../types/app'; import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = { type AssistantThinkingIndicatorProps = {
selectedProvider: SessionProvider; selectedProvider: SessionProvider;
@@ -11,15 +10,15 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
return ( return (
<div className="chat-message assistant"> <div className="chat-message assistant">
<div className="w-full"> <div className="w-full">
<div className="flex items-center space-x-3 mb-2"> <div className="mb-2 flex items-center space-x-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-transparent p-1 text-sm text-white">
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" /> <SessionProviderLogo provider={selectedProvider} className="h-full w-full" />
</div> </div>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'} {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
</div> </div>
</div> </div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0"> <div className="w-full pl-3 text-sm text-gray-500 dark:text-gray-400 sm:pl-0">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className="animate-pulse">.</div> <div className="animate-pulse">.</div>
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}> <div className="animate-pulse" style={{ animationDelay: '0.2s' }}>

View File

@@ -1,9 +1,3 @@
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import MicButton from '../../../mic-button/view/MicButton';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { import type {
ChangeEvent, ChangeEvent,
@@ -17,7 +11,13 @@ import type {
SetStateAction, SetStateAction,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
interface MentionableFile { interface MentionableFile {
name: string; name: string;
@@ -169,7 +169,7 @@ export default function ChatComposer({
: ''; : '';
return ( return (
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}> <div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && ( {!hasQuestionPanel && (
<div className="flex-1"> <div className="flex-1">
<ClaudeStatus <ClaudeStatus
@@ -181,7 +181,7 @@ export default function ChatComposer({
</div> </div>
)} )}
<div className="max-w-4xl mx-auto mb-3"> <div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner <PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests} pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision} handlePermissionDecision={handlePermissionDecision}
@@ -205,11 +205,11 @@ export default function ChatComposer({
/>} />}
</div> </div>
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto"> {!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative mx-auto max-w-4xl">
{isDragActive && ( {isDragActive && (
<div className="absolute inset-0 bg-primary/15 border-2 border-dashed border-primary/50 rounded-2xl flex items-center justify-center z-50"> <div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="bg-card rounded-xl p-4 shadow-lg border border-border/30"> <div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="w-8 h-8 text-primary mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -223,7 +223,7 @@ export default function ChatComposer({
)} )}
{attachedImages.length > 0 && ( {attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-muted/40 rounded-xl"> <div className="mb-2 rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => ( {attachedImages.map((file, index) => (
<ImageAttachment <ImageAttachment
@@ -239,14 +239,14 @@ export default function ChatComposer({
)} )}
{showFileDropdown && filteredFiles.length > 0 && ( {showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card/95 backdrop-blur-md border border-border/50 rounded-xl shadow-lg max-h-48 overflow-y-auto z-50"> <div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => ( {filteredFiles.map((file, index) => (
<div <div
key={file.path} key={file.path}
className={`px-4 py-3 cursor-pointer border-b border-border/30 last:border-b-0 touch-manipulation ${ className={`cursor-pointer touch-manipulation border-b border-border/30 px-4 py-3 last:border-b-0 ${
index === selectedFileIndex index === selectedFileIndex
? 'bg-primary/8 text-primary' ? 'bg-primary/8 text-primary'
: 'hover:bg-accent/50 text-foreground' : 'text-foreground hover:bg-accent/50'
}`} }`}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
@@ -258,8 +258,8 @@ export default function ChatComposer({
onSelectFile(file); onSelectFile(file);
}} }}
> >
<div className="font-medium text-sm">{file.name}</div> <div className="text-sm font-medium">{file.name}</div>
<div className="text-xs text-muted-foreground font-mono">{file.path}</div> <div className="font-mono text-xs text-muted-foreground">{file.path}</div>
</div> </div>
))} ))}
</div> </div>
@@ -277,13 +277,13 @@ export default function ChatComposer({
<div <div
{...getRootProps()} {...getRootProps()}
className={`relative bg-card/80 backdrop-blur-sm rounded-2xl shadow-sm border border-border/50 focus-within:shadow-md focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/15 transition-all duration-200 overflow-hidden ${ className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${
isTextareaExpanded ? 'chat-input-expanded' : '' isTextareaExpanded ? 'chat-input-expanded' : ''
}`} }`}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
<div ref={inputHighlightRef} aria-hidden="true" className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"> <div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words"> <div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40">
{renderInputWithMentions(input)} {renderInputWithMentions(input)}
</div> </div>
</div> </div>
@@ -302,17 +302,17 @@ export default function ChatComposer({
onInput={onTextareaInput} onInput={onTextareaInput}
placeholder={placeholder} placeholder={placeholder}
disabled={isLoading} disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-foreground placeholder-muted-foreground/50 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200" className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }} style={{ height: '50px' }}
/> />
<button <button
type="button" type="button"
onClick={openImagePicker} onClick={openImagePicker}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-accent/60 rounded-xl transition-colors" className="absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60"
title={t('input.attachImages')} title={t('input.attachImages')}
> >
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -322,8 +322,8 @@ export default function ChatComposer({
</svg> </svg>
</button> </button>
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}> <div className="absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16" style={{ display: 'none' }}>
<MicButton onTranscript={onTranscript} className="w-10 h-10 sm:w-10 sm:h-10" /> <MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" />
</div> </div>
<button <button
@@ -337,15 +337,15 @@ export default function ChatComposer({
event.preventDefault(); event.preventDefault();
onSubmit(event); onSubmit(event);
}} }}
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-11 sm:h-11 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed rounded-xl flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background" className="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11"
> >
<svg className="w-4 h-4 sm:w-[18px] sm:h-[18px] text-primary-foreground transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg> </svg>
</button> </button>
<div <div
className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-muted-foreground/50 pointer-events-none hidden sm:block transition-opacity duration-200 ${ className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${
input.trim() ? 'opacity-0' : 'opacity-100' input.trim() ? 'opacity-0' : 'opacity-100'
}`} }`}
> >

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector'; import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie'; import TokenUsagePie from './TokenUsagePie';
import type { PermissionMode, Provider } from '../../types/types';
interface ChatInputControlsProps { interface ChatInputControlsProps {
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
@@ -38,24 +38,24 @@ export default function ChatInputControls({
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
return ( return (
<div className="flex items-center justify-center gap-2 sm:gap-3 flex-wrap"> <div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
<button <button
type="button" type="button"
onClick={onModeSwitch} onClick={onModeSwitch}
className={`px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${ className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${
permissionMode === 'default' permissionMode === 'default'
? 'bg-muted/50 text-muted-foreground border-border/60 hover:bg-muted' ? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/15 text-green-700 dark:text-green-300 border-green-300/60 dark:border-green-600/40 hover:bg-green-100 dark:hover:bg-green-900/25' ? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions' : permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/15 text-orange-700 dark:text-orange-300 border-orange-300/60 dark:border-orange-600/40 hover:bg-orange-100 dark:hover:bg-orange-900/25' ? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'bg-primary/5 text-primary border-primary/20 hover:bg-primary/10' : 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`} }`}
title={t('input.clickToChangeMode')} title={t('input.clickToChangeMode')}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className={`w-1.5 h-1.5 rounded-full ${ className={`h-1.5 w-1.5 rounded-full ${
permissionMode === 'default' permissionMode === 'default'
? 'bg-muted-foreground' ? 'bg-muted-foreground'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
@@ -83,10 +83,10 @@ export default function ChatInputControls({
<button <button
type="button" type="button"
onClick={onToggleCommandMenu} onClick={onToggleCommandMenu}
className="relative w-7 h-7 sm:w-8 sm:h-8 text-muted-foreground hover:text-foreground rounded-lg flex items-center justify-center transition-colors hover:bg-accent/60" className="relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8"
title={t('input.showAllCommands')} title={t('input.showAllCommands')}
> >
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -96,7 +96,7 @@ export default function ChatInputControls({
</svg> </svg>
{slashCommandsCount > 0 && ( {slashCommandsCount > 0 && (
<span <span
className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[10px] font-bold rounded-full w-4 h-4 sm:w-5 sm:h-5 flex items-center justify-center" className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5"
> >
{slashCommandsCount} {slashCommandsCount}
</span> </span>
@@ -107,11 +107,11 @@ export default function ChatInputControls({
<button <button
type="button" type="button"
onClick={onClearInput} onClick={onClearInput}
className="w-7 h-7 sm:w-8 sm:h-8 bg-card hover:bg-accent/60 border border-border/50 rounded-lg flex items-center justify-center transition-all duration-200 group shadow-sm" className="group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8"
title={t('input.clearInput', { defaultValue: 'Clear input' })} title={t('input.clearInput', { defaultValue: 'Clear input' })}
> >
<svg <svg
className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground group-hover:text-foreground transition-colors" className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -124,10 +124,10 @@ export default function ChatInputControls({
{isUserScrolledUp && hasMessages && ( {isUserScrolledUp && hasMessages && (
<button <button
onClick={onScrollToBottom} onClick={onScrollToBottom}
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105" className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })} title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
> >
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg> </svg>
</button> </button>

View File

@@ -1,13 +1,12 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import type { ChatMessage } from '../../types/types'; import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app'; import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
interface ChatMessagesPaneProps { interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>; scrollContainerRef: RefObject<HTMLDivElement>;
@@ -134,12 +133,12 @@ export default function ChatMessagesPane({
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={onWheel} onWheel={onWheel}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative" className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
> >
{isLoadingSessionMessages && chatMessages.length === 0 ? ( {isLoadingSessionMessages && chatMessages.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 mt-8"> <div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" /> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
<p>{t('session.loading.sessionMessages')}</p> <p>{t('session.loading.sessionMessages')}</p>
</div> </div>
</div> </div>
@@ -167,9 +166,9 @@ export default function ChatMessagesPane({
<> <>
{/* Loading indicator for older messages (hide when load-all is active) */} {/* Loading indicator for older messages (hide when load-all is active) */}
{isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && ( {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
<div className="text-center text-gray-500 dark:text-gray-400 py-3"> <div className="py-3 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" /> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
<p className="text-sm">{t('session.loading.olderMessages')}</p> <p className="text-sm">{t('session.loading.olderMessages')}</p>
</div> </div>
</div> </div>
@@ -177,7 +176,7 @@ export default function ChatMessagesPane({
{/* Indicator showing there are more messages to load (hide when all loaded) */} {/* Indicator showing there are more messages to load (hide when all loaded) */}
{hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && ( {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{totalMessages > 0 && ( {totalMessages > 0 && (
<span> <span>
{t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '} {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '}
@@ -189,22 +188,22 @@ export default function ChatMessagesPane({
{/* Floating "Load all messages" overlay */} {/* Floating "Load all messages" overlay */}
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && ( {(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
<div className="sticky top-2 z-20 flex justify-center pointer-events-none"> <div className="pointer-events-none sticky top-2 z-20 flex justify-center">
{loadAllJustFinished ? ( {loadAllJustFinished ? (
<div className="px-4 py-1.5 text-xs font-medium text-white bg-green-600 dark:bg-green-500 rounded-full shadow-lg flex items-center space-x-2"> <div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
<span>{t('session.messages.allLoaded')}</span> <span>{t('session.messages.allLoaded')}</span>
</div> </div>
) : ( ) : (
<button <button
className="pointer-events-auto px-4 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded-full shadow-lg transition-all duration-200 hover:scale-105 disabled:opacity-75 disabled:cursor-wait flex items-center space-x-2" className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={loadAllMessages} onClick={loadAllMessages}
disabled={isLoadingAllMessages} disabled={isLoadingAllMessages}
> >
{isLoadingAllMessages && ( {isLoadingAllMessages && (
<div className="animate-spin rounded-full h-3 w-3 border-2 border-white/30 border-t-white" /> <div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)} )}
<span> <span>
{isLoadingAllMessages {isLoadingAllMessages
@@ -219,21 +218,21 @@ export default function ChatMessagesPane({
{/* Performance warning when all messages are loaded */} {/* Performance warning when all messages are loaded */}
{allMessagesLoaded && ( {allMessagesLoaded && (
<div className="text-center text-amber-600 dark:text-amber-400 text-xs py-1.5 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"> <div className="border-b border-amber-200 bg-amber-50 py-1.5 text-center text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400">
{t('session.messages.perfWarning')} {t('session.messages.perfWarning')}
</div> </div>
)} )}
{/* Legacy message count indicator (for non-paginated view) */} {/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && ( {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} | {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |
<button className="ml-1 text-blue-600 hover:text-blue-700 underline" onClick={loadEarlierMessages}> <button className="ml-1 text-blue-600 underline hover:text-blue-700" onClick={loadEarlierMessages}>
{t('session.messages.loadEarlier')} {t('session.messages.loadEarlier')}
</button> </button>
{' | '} {' | '}
<button <button
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline" className="text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onClick={loadAllMessages} onClick={loadAllMessages}
> >
{t('session.messages.loadAll')} {t('session.messages.loadAll')}
@@ -247,7 +246,6 @@ export default function ChatMessagesPane({
<MessageComponent <MessageComponent
key={getMessageKey(message)} key={getMessageKey(message)}
message={message} message={message}
index={index}
prevMessage={prevMessage} prevMessage={prevMessage}
createDiff={createDiff} createDiff={createDiff}
onFileOpen={onFileOpen} onFileOpen={onFileOpen}

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../lib/utils'; import { cn } from '../../../../lib/utils';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
type ClaudeStatusProps = { type ClaudeStatusProps = {
status: { status: {
@@ -12,33 +14,60 @@ type ClaudeStatusProps = {
provider?: string; provider?: string;
}; };
const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; const ACTION_KEYS = [
const SPINNER_CHARS = ['*', '+', 'x', '.']; 'claudeStatus.actions.thinking',
'claudeStatus.actions.processing',
'claudeStatus.actions.analyzing',
'claudeStatus.actions.working',
'claudeStatus.actions.computing',
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const ANIMATION_STEPS = 40;
const PROVIDER_LABEL_KEYS: Record<string, string> = {
claude: 'messageTypes.claude',
codex: 'messageTypes.codex',
cursor: 'messageTypes.cursor',
gemini: 'messageTypes.gemini',
};
function formatElapsedTime(totalSeconds: number, t: (key: string, options?: Record<string, unknown>) => string) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 1) {
return t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' });
}
return t('claudeStatus.elapsed.minutesSeconds', {
minutes,
seconds,
defaultValue: '{{minutes}}m {{seconds}}s',
});
}
export default function ClaudeStatus({ export default function ClaudeStatus({
status, status,
onAbort, onAbort,
isLoading, isLoading,
provider: _provider = 'claude', provider = 'claude',
}: ClaudeStatusProps) { }: ClaudeStatusProps) {
const { t } = useTranslation('chat');
const [elapsedTime, setElapsedTime] = useState(0); const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0); const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);
useEffect(() => { useEffect(() => {
if (!isLoading) { if (!isLoading) {
setElapsedTime(0); setElapsedTime(0);
setFakeTokens(0);
return; return;
} }
const startTime = Date.now(); const startTime = Date.now();
const tokenRate = 30 + Math.random() * 20;
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000); const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed); setElapsedTime(elapsed);
setFakeTokens(Math.floor(elapsed * tokenRate));
}, 1000); }, 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
@@ -50,68 +79,118 @@ export default function ClaudeStatus({
} }
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length); setAnimationPhase((previous) => (previous + 1) % ANIMATION_STEPS);
}, 500); }, 500);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [isLoading]); }, [isLoading]);
if (!isLoading) { // Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
if (!isLoading && !status) {
return null; return null;
} }
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator const actionWords = ACTION_KEYS.map((key, index) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[index] }));
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length; const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
const statusText = status?.text || ACTION_WORDS[actionIndex]; const statusText = status?.text || actionWords[actionIndex];
const tokens = status?.tokens || fakeTokens; const cleanStatusText = statusText.replace(/[.]+$/, '');
const canInterrupt = status?.can_interrupt !== false; const canInterrupt = isLoading && status?.can_interrupt !== false;
const currentSpinner = SPINNER_CHARS[animationPhase]; const providerLabelKey = PROVIDER_LABEL_KEYS[provider];
const providerLabel = providerLabelKey
? t(providerLabelKey)
: t('claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
const animatedDots = '.'.repeat((animationPhase % 3) + 1);
const elapsedLabel =
elapsedTime > 0
? t('claudeStatus.elapsed.label', {
time: formatElapsedTime(elapsedTime, t),
defaultValue: '{{time}} elapsed',
})
: t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' });
return ( return (
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300"> <div className="animate-in slide-in-from-bottom mb-3 w-full duration-300 sm:mb-6">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800"> <div className="relative mx-auto max-w-4xl overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md">
<div className="flex-1 min-w-0"> <div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20" />
<div className="flex items-center gap-2 sm:gap-3">
<span
className={cn(
'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
)}
>
{currentSpinner}
</span>
<div className="flex-1 min-w-0"> <div className="relative px-3 py-3 sm:px-4 sm:py-3.5">
<div className="flex items-center gap-1.5 sm:gap-2"> <div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span> <div className="flex min-w-0 items-start gap-3" role="status" aria-live="polite">
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span> <div className="relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-primary/25 bg-primary/10">
{tokens > 0 && ( <SessionProviderLogo provider={provider} className="h-5 w-5" />
<> <span className="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
<span className="text-gray-500 hidden sm:inline">|</span> {isLoading && (
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0"> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/70" />
tokens {tokens.toLocaleString()} )}
<span
className={cn(
'relative inline-flex h-2.5 w-2.5 rounded-full',
isLoading ? 'bg-emerald-400' : 'bg-amber-400',
)}
/>
</span>
</div>
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
<span>{providerLabel}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-[9px] tracking-[0.14em]',
isLoading
? 'bg-emerald-500/15 text-emerald-500 dark:text-emerald-400'
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
)}
>
{isLoading
? t('claudeStatus.state.live', { defaultValue: 'Live' })
: t('claudeStatus.state.paused', { defaultValue: 'Paused' })}
</span>
</div>
<p className="truncate text-sm font-semibold text-foreground sm:text-[15px]">
{cleanStatusText}
{isLoading && (
<span aria-hidden="true" className="text-primary">
{animatedDots}
</span> </span>
</> )}
)} </p>
<span className="text-gray-500 hidden sm:inline">|</span>
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span> <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs">
<span
aria-hidden="true"
className="-ml-2 inline-flex items-center rounded-full border border-border/70 bg-background/60 px-2 py-0.5"
>
{elapsedLabel}
</span>
</div>
</div> </div>
</div> </div>
{canInterrupt && onAbort && (
<div className="w-full sm:w-auto sm:text-right">
<button
type="button"
onClick={onAbort}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 active:opacity-90 sm:w-auto"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{t('claudeStatus.controls.stopGeneration', { defaultValue: 'Stop Generation' })}</span>
<span className="rounded-md bg-black/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-destructive-foreground/95">
Esc
</span>
</button>
<p className="mt-1 hidden text-[11px] text-muted-foreground sm:block">
{t('claudeStatus.controls.pressEscToStop', { defaultValue: 'Press Esc anytime to stop' })}
</p>
</div>
)}
</div> </div>
</div> </div>
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="hidden sm:inline">Stop</span>
</button>
)}
</div> </div>
</div> </div>
); );

View File

@@ -17,16 +17,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
}, [file]); }, [file]);
return ( return (
<div className="relative group"> <div className="group relative">
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" /> <img src={preview} alt={file.name} className="h-20 w-20 rounded object-cover" />
{uploadProgress !== undefined && uploadProgress < 100 && ( {uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-white text-xs">{uploadProgress}%</div> <div className="text-xs text-white">{uploadProgress}%</div>
</div> </div>
)} )}
{error && ( {error && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center bg-red-500/50">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </div>
@@ -34,10 +34,10 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
aria-label="Remove image" aria-label="Remove image"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>

View File

@@ -32,7 +32,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
if (shouldInline) { if (shouldInline) {
return ( return (
<code <code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || '' className={`whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] text-gray-900 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-100 ${className || ''
}`} }`}
{...props} {...props}
> >
@@ -45,9 +45,9 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const language = match ? match[1] : 'text'; const language = match ? match[1] : 'text';
return ( return (
<div className="relative group my-2"> <div className="group relative my-2">
{language && language !== 'text' && ( {language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div> <div className="absolute left-3 top-2 z-10 text-xs font-medium uppercase text-gray-400">{language}</div>
)} )}
<button <button
@@ -60,13 +60,13 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
} }
}) })
} }
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600" className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
> >
{copied ? ( {copied ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
@@ -78,7 +78,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
) : ( ) : (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg <svg
className="w-3.5 h-3.5" className="h-3.5 w-3.5"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -119,27 +119,27 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = { const markdownComponents = {
code: CodeBlock, code: CodeBlock,
blockquote: ({ children }: { children?: React.ReactNode }) => ( blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2"> <blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children} {children}
</blockquote> </blockquote>
), ),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer"> <a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
{children} {children}
</a> </a>
), ),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>, p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => ( table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-2"> <div className="my-2 overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table> <table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div> </div>
), ),
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>, thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }: { children?: React.ReactNode }) => ( th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th> <th className="border border-gray-200 px-3 py-2 text-left text-sm font-semibold dark:border-gray-700">{children}</th>
), ),
td: ({ children }: { children?: React.ReactNode }) => ( td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td> <td className="border border-gray-200 px-3 py-2 align-top text-sm dark:border-gray-700">{children}</td>
), ),
}; };

View File

@@ -7,12 +7,12 @@ import type {
PermissionGrantResult, PermissionGrantResult,
Provider, Provider,
} from '../../types/types'; } from '../../types/types';
import { Markdown } from './Markdown';
import { formatUsageLimitText } from '../../utils/chatFormatting'; import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions'; import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Markdown } from './Markdown';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -22,7 +22,6 @@ type DiffLine = {
interface MessageComponentProps { interface MessageComponentProps {
message: ChatMessage; message: ChatMessage;
index: number;
prevMessage: ChatMessage | null; prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[]; createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
@@ -43,7 +42,7 @@ type InteractiveOption = {
type PermissionGrantState = 'idle' | 'granted' | 'error'; type PermissionGrantState = 'idle' | 'granted' | 'error';
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
@@ -97,13 +96,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return ( return (
<div <div
ref={messageRef} ref={messageRef}
data-message-timestamp={message.timestamp || undefined}
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`} className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
> >
{message.type === 'user' ? ( {message.type === 'user' ? (
/* User message bubble on the right */ /* User message bubble on the right */
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial group"> <div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div className="text-sm whitespace-pre-wrap break-words"> <div className="whitespace-pre-wrap break-words text-sm">
{message.content} {message.content}
</div> </div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
@@ -113,13 +113,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
key={img.name || idx} key={img.name || idx}
src={img.data} src={img.data}
alt={img.name} alt={img.name}
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity" className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
onClick={() => window.open(img.data, '_blank')} onClick={() => window.open(img.data, '_blank')}
/> />
))} ))}
</div> </div>
)} )}
<div className="flex items-center justify-end gap-1 mt-1 text-xs text-blue-100"> <div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -135,7 +135,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')} aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
> >
{messageCopied ? ( {messageCopied ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
@@ -144,7 +144,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</svg> </svg>
) : ( ) : (
<svg <svg
className="w-3.5 h-3.5" className="h-3.5 w-3.5"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -161,7 +161,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div> </div>
</div> </div>
{!isGrouped && ( {!isGrouped && (
<div className="hidden sm:flex w-8 h-8 bg-blue-600 rounded-full items-center justify-center text-white text-sm flex-shrink-0"> <div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">
U U
</div> </div>
)} )}
@@ -170,7 +170,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
/* Compact task notification on the left */ /* Compact task notification on the left */
<div className="w-full"> <div className="w-full">
<div className="flex items-center gap-2 py-0.5"> <div className="flex items-center gap-2 py-0.5">
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} /> <span className={`inline-block h-1.5 w-1.5 flex-shrink-0 rounded-full ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span> <span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
</div> </div>
</div> </div>
@@ -178,18 +178,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
/* Claude/Error/Tool messages on the left */ /* Claude/Error/Tool messages on the left */
<div className="w-full"> <div className="w-full">
{!isGrouped && ( {!isGrouped && (
<div className="flex items-center space-x-3 mb-2"> <div className="mb-2 flex items-center space-x-3">
{message.type === 'error' ? ( {message.type === 'error' ? (
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-600 text-sm text-white">
! !
</div> </div>
) : message.type === 'tool' ? ( ) : message.type === 'tool' ? (
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-sm text-white dark:bg-gray-700">
🔧 🔧
</div> </div>
) : ( ) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
<SessionProviderLogo provider={provider} className="w-full h-full" /> <SessionProviderLogo provider={provider} className="h-full w-full" />
</div> </div>
)} )}
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">
@@ -234,20 +234,20 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Error results - red error box with content // Error results - red error box with content
<div <div
id={`tool-result-${message.toolId}`} id={`tool-result-${message.toolId}`}
className="relative mt-2 p-3 rounded border scroll-mt-4 bg-red-50/50 dark:bg-red-950/10 border-red-200/60 dark:border-red-800/40" className="relative mt-2 scroll-mt-4 rounded border border-red-200/60 bg-red-50/50 p-3 dark:border-red-800/40 dark:bg-red-950/10"
> >
<div className="relative flex items-center gap-1.5 mb-2"> <div className="relative mb-2 flex items-center gap-1.5">
<svg className="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span> <span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div> </div>
<div className="relative text-sm text-red-900 dark:text-red-100"> <div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert"> <Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')} {String(message.toolResult.content || '')}
</Markdown> </Markdown>
{permissionSuggestion && ( {permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3"> <div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
@@ -261,9 +261,9 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
} }
}} }}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'} disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted' className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default' ? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70' : 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`} }`}
> >
{permissionSuggestion.isAllowed || permissionGrantState === 'granted' {permissionSuggestion.isAllowed || permissionGrantState === 'granted'
@@ -274,7 +274,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }} onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100" className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
> >
{t('permissions.openSettings')} {t('permissions.openSettings')}
</button> </button>
@@ -317,15 +317,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</> </>
) : message.isInteractivePrompt ? ( ) : message.isInteractivePrompt ? (
// Special handling for interactive prompts // Special handling for interactive prompts
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4"> <div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-500">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3"> <h4 className="mb-3 text-base font-semibold text-amber-900 dark:text-amber-100">
{t('interactive.title')} {t('interactive.title')}
</h4> </h4>
{(() => { {(() => {
@@ -349,29 +349,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return ( return (
<> <>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4"> <p className="mb-4 text-sm text-amber-800 dark:text-amber-200">
{questionLine} {questionLine}
</p> </p>
{/* Option buttons */} {/* Option buttons */}
<div className="space-y-2 mb-4"> <div className="mb-4 space-y-2">
{options.map((option) => ( {options.map((option) => (
<button <button
key={option.number} key={option.number}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected className={`w-full rounded-lg border-2 px-4 py-3 text-left transition-all ${option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md' ? 'border-amber-600 bg-amber-600 text-white shadow-md dark:border-amber-700 dark:bg-amber-700'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700' : 'border-amber-300 bg-white text-amber-900 dark:border-amber-700 dark:bg-gray-800 dark:text-amber-100'
} cursor-not-allowed opacity-75`} } cursor-not-allowed opacity-75`}
disabled disabled
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected <span className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-bold ${option.isSelected
? 'bg-white/20' ? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50' : 'bg-amber-100 dark:bg-amber-800/50'
}`}> }`}>
{option.number} {option.number}
</span> </span>
<span className="text-sm sm:text-base font-medium flex-1"> <span className="flex-1 text-sm font-medium sm:text-base">
{option.text} {option.text}
</span> </span>
{option.isSelected && ( {option.isSelected && (
@@ -382,11 +382,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
))} ))}
</div> </div>
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3"> <div className="rounded-lg bg-amber-100 p-3 dark:bg-amber-800/30">
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1"> <p className="mb-1 text-sm font-medium text-amber-900 dark:text-amber-100">
{t('interactive.waiting')} {t('interactive.waiting')}
</p> </p>
<p className="text-amber-800 dark:text-amber-200 text-xs"> <p className="text-xs text-amber-800 dark:text-amber-200">
{t('interactive.instruction')} {t('interactive.instruction')}
</p> </p>
</div> </div>
@@ -400,14 +400,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
/* Thinking messages - collapsible by default */ /* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group"> <details className="group">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2"> <summary className="flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<span>{t('thinking.emoji')}</span> <span>{t('thinking.emoji')}</span>
</summary> </summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm"> <div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray"> <Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content} {message.content}
</Markdown> </Markdown>
</div> </div>
@@ -418,10 +418,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{/* Thinking accordion for reasoning */} {/* Thinking accordion for reasoning */}
{showThinking && message.reasoning && ( {showThinking && message.reasoning && (
<details className="mb-3"> <details className="mb-3">
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium"> <summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
{t('thinking.emoji')} {t('thinking.emoji')}
</summary> </summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm"> <div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
<div className="whitespace-pre-wrap"> <div className="whitespace-pre-wrap">
{message.reasoning} {message.reasoning}
</div> </div>
@@ -442,15 +442,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return ( return (
<div className="my-2"> <div className="my-2">
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400"> <div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg> </svg>
<span className="font-medium">{t('json.response')}</span> <span className="font-medium">{t('json.response')}</span>
</div> </div>
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
<pre className="p-4 overflow-x-auto"> <pre className="overflow-x-auto p-4">
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre"> <code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
{formatted} {formatted}
</code> </code>
</pre> </pre>
@@ -464,7 +464,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Normal rendering for non-JSON content // Normal rendering for non-JSON content
return message.type === 'assistant' ? ( return message.type === 'assistant' ? (
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray"> <Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{content} {content}
</Markdown> </Markdown>
) : ( ) : (
@@ -477,7 +477,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)} )}
{!isGrouped && ( {!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1"> <div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
{formattedTime} {formattedTime}
</div> </div>
)} )}

View File

@@ -56,7 +56,7 @@ export default function PermissionRequestsBanner({
return ( return (
<div <div
key={request.requestId} key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm" className="rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20"
> >
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -74,10 +74,10 @@ export default function PermissionRequestsBanner({
{rawInput && ( {rawInput && (
<details className="mt-2"> <details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100"> <summary className="cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100">
View tool input View tool input
</summary> </summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap"> <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100">
{rawInput} {rawInput}
</pre> </pre>
</details> </details>
@@ -87,7 +87,7 @@ export default function PermissionRequestsBanner({
<button <button
type="button" type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })} onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors" className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700"
> >
Allow once Allow once
</button> </button>
@@ -99,10 +99,10 @@ export default function PermissionRequestsBanner({
} }
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry }); handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}} }}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${ className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
permissionEntry permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30' ? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed' : 'cursor-not-allowed border-gray-300 text-gray-400'
}`} }`}
disabled={!permissionEntry} disabled={!permissionEntry}
> >
@@ -111,7 +111,7 @@ export default function PermissionRequestsBanner({
<button <button
type="button" type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })} onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors" className="inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30"
> >
Deny Deny
</button> </button>

View File

@@ -2,9 +2,9 @@ import React from 'react';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app'; import type { ProjectSession, SessionProvider } from '../../../../types/app';
import { NextTaskBanner } from '../../../task-master';
interface ProviderSelectionEmptyStateProps { interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -125,20 +125,20 @@ export default function ProviderSelectionEmptyState({
/* ── New session — provider picker ── */ /* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) { if (!selectedSession && !currentSessionId) {
return ( return (
<div className="flex items-center justify-center h-full px-4"> <div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Heading */} {/* Heading */}
<div className="text-center mb-8"> <div className="mb-8 text-center">
<h2 className="text-lg sm:text-xl font-semibold text-foreground tracking-tight"> <h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t('providerSelection.title')} {t('providerSelection.title')}
</h2> </h2>
<p className="text-[13px] text-muted-foreground mt-1"> <p className="mt-1 text-[13px] text-muted-foreground">
{t('providerSelection.description')} {t('providerSelection.description')}
</p> </p>
</div> </div>
{/* Provider cards — horizontal row, equal width */} {/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6"> <div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5">
{PROVIDERS.map((p) => { {PROVIDERS.map((p) => {
const active = provider === p.id; const active = provider === p.id;
return ( return (
@@ -146,27 +146,27 @@ export default function ProviderSelectionEmptyState({
key={p.id} key={p.id}
onClick={() => selectProvider(p.id)} onClick={() => selectProvider(p.id)}
className={` className={`
relative flex flex-col items-center gap-2.5 pt-5 pb-4 px-2 relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
rounded-xl border-[1.5px] transition-all duration-150 pb-4 pt-5 transition-all duration-150
active:scale-[0.97] active:scale-[0.97]
${active ${active
? `${p.accent} ${p.ring} ring-2 bg-card shadow-sm` ? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: 'border-border bg-card/60 hover:bg-card hover:border-border/80' : 'border-border bg-card/60 hover:border-border/80 hover:bg-card'
} }
`} `}
> >
<SessionProviderLogo <SessionProviderLogo
provider={p.id} provider={p.id}
className={`w-9 h-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`} className={`h-9 w-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
/> />
<div className="text-center"> <div className="text-center">
<p className="text-[13px] font-semibold text-foreground leading-none">{p.name}</p> <p className="text-[13px] font-semibold leading-none text-foreground">{p.name}</p>
<p className="text-[10px] text-muted-foreground mt-1 leading-tight">{t(p.infoKey)}</p> <p className="mt-1 text-[10px] leading-tight text-muted-foreground">{t(p.infoKey)}</p>
</div> </div>
{/* Check badge */} {/* Check badge */}
{active && ( {active && (
<div className={`absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}> <div className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<Check className="w-2.5 h-2.5" strokeWidth={3} /> <Check className="h-2.5 w-2.5" strokeWidth={3} />
</div> </div>
)} )}
</button> </button>
@@ -175,21 +175,21 @@ export default function ProviderSelectionEmptyState({
</div> </div>
{/* Model picker — appears after provider is chosen */} {/* Model picker — appears after provider is chosen */}
<div className={`transition-all duration-200 ${provider ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}> <div className={`transition-all duration-200 ${provider ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'}`}>
<div className="flex items-center justify-center gap-2 mb-5"> <div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span> <span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
<div className="relative"> <div className="relative">
<select <select
value={currentModel} value={currentModel}
onChange={(e) => handleModelChange(e.target.value)} onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1} tabIndex={-1}
className="appearance-none pl-3 pr-7 py-1.5 text-sm font-medium bg-muted/50 border border-border/60 rounded-lg text-foreground cursor-pointer hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20" className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
> >
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => ( {modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option> <option key={value} value={value}>{label}</option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div> </div>
</div> </div>
@@ -219,10 +219,10 @@ export default function ProviderSelectionEmptyState({
/* ── Existing session — continue prompt ── */ /* ── Existing session — continue prompt ── */
if (selectedSession) { if (selectedSession) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-center px-6 max-w-md"> <div className="max-w-md px-6 text-center">
<p className="text-lg font-semibold text-foreground mb-1.5">{t('session.continue.title')}</p> <p className="mb-1.5 text-lg font-semibold text-foreground">{t('session.continue.title')}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{t('session.continue.description')}</p> <p className="text-sm leading-relaxed text-muted-foreground">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && ( {tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5"> <div className="mt-5">

View File

@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Brain, X } from 'lucide-react'; import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes'; import { thinkingModes } from '../../constants/thinkingModes';
type ThinkingModeSelectorProps = { type ThinkingModeSelectorProps = {
@@ -53,18 +52,18 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={`w-10 h-10 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-200 ${selectedMode === 'none' className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600' ? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800' : 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`} }`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })} title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
> >
<IconComponent className={`w-5 h-5 ${currentMode.color}`} /> <IconComponent className={`h-5 w-5 ${currentMode.color}`} />
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute bottom-full right-0 mb-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden"> <div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="p-3 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')} {t('thinkingMode.selector.title')}
@@ -74,12 +73,12 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
setIsOpen(false); setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
}} }}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<X className="w-4 h-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
</button> </button>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('thinkingMode.selector.description')} {t('thinkingMode.selector.description')}
</p> </p>
</div> </div>
@@ -97,30 +96,30 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
setIsOpen(false); setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
}} }}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : '' className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}> <div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
{ModeIcon ? <ModeIcon className="w-5 h-5" /> : <div className="w-5 h-5" />} {ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-medium text-sm ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300' <span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
}`}> }`}>
{mode.name} {mode.name}
</span> </span>
{isSelected && ( {isSelected && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded"> <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t('thinkingMode.selector.active')} {t('thinkingMode.selector.active')}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{mode.description} {mode.description}
</p> </p>
{mode.prefix && ( {mode.prefix && (
<code className="text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded mt-1 inline-block"> <code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
{mode.prefix} {mode.prefix}
</code> </code>
)} )}
@@ -131,7 +130,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
})} })}
</div> </div>
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"> <div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Tip:</strong> {t('thinkingMode.selector.tip')} <strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p> </p>

View File

@@ -22,7 +22,7 @@ export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
return ( return (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" className="transform -rotate-90"> <svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
{/* Background circle */} {/* Background circle */}
<circle <circle
cx="12" cx="12"

View File

@@ -220,7 +220,7 @@ export default function CodeEditor({
/> />
{saveError && ( {saveError && (
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40"> <div className="border-b border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
{saveError} {saveError}
</div> </div>
)} )}

View File

@@ -102,20 +102,20 @@ export default function EditorSidebar({
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth); const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return ( return (
<div ref={containerRef} className={`flex h-full flex-shrink-0 min-w-0 ${editorExpanded ? 'flex-1' : ''}`}> <div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
{!editorExpanded && ( {!editorExpanded && (
<div <div
ref={resizeHandleRef} ref={resizeHandleRef}
onMouseDown={onResizeStart} onMouseDown={onResizeStart}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group" className="group relative w-1 flex-shrink-0 cursor-col-resize bg-gray-200 transition-colors hover:bg-blue-500 dark:bg-gray-700 dark:hover:bg-blue-600"
title="Drag to resize" title="Drag to resize"
> >
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100 dark:bg-blue-600" />
</div> </div>
)} )}
<div <div
className={`border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1 min-w-0' : `flex-shrink-0 min-w-[${MIN_EDITOR_WIDTH}px]`}`} className={`h-full overflow-hidden border-l border-gray-200 dark:border-gray-700 ${useFlexLayout ? 'min-w-0 flex-1' : `min-w-[ flex-shrink-0${MIN_EDITOR_WIDTH}px]`}`}
style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }} style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }}
> >
<CodeEditor <CodeEditor

View File

@@ -20,20 +20,20 @@ export default function CodeEditorBinaryFile({
message, message,
}: CodeEditorBinaryFileProps) { }: CodeEditorBinaryFileProps) {
const binaryContent = ( const binaryContent = (
<div className="w-full h-full flex flex-col items-center justify-center bg-background text-muted-foreground p-8"> <div className="flex h-full w-full flex-col items-center justify-center bg-background p-8 text-muted-foreground">
<div className="flex flex-col items-center gap-4 max-w-md text-center"> <div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg className="w-8 h-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-8 w-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-lg font-medium text-foreground mb-2">{title}</h3> <h3 className="mb-2 text-lg font-medium text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">{message}</p> <p className="text-sm text-muted-foreground">{message}</p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors" className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
> >
Close Close
</button> </button>
@@ -43,18 +43,18 @@ export default function CodeEditorBinaryFile({
if (isSidebar) { if (isSidebar) {
return ( return (
<div className="w-full h-full flex flex-col bg-background"> <div className="flex h-full w-full flex-col bg-background">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0"> <div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3> <h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title="Close" title="Close"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -75,23 +75,23 @@ export default function CodeEditorBinaryFile({
return ( return (
<div className={containerClassName}> <div className={containerClassName}>
<div className={innerClassName}> <div className={innerClassName}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0"> <div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3> <h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex shrink-0 items-center gap-0.5">
<button <button
type="button" type="button"
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
> >
{isFullscreen ? ( {isFullscreen ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg> </svg>
)} )}
@@ -99,10 +99,10 @@ export default function CodeEditorBinaryFile({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title="Close" title="Close"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>

View File

@@ -12,7 +12,7 @@ export default function CodeEditorFooter({
shortcutsLabel, shortcutsLabel,
}: CodeEditorFooterProps) { }: CodeEditorFooterProps) {
return ( return (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0"> <div className="flex flex-shrink-0 items-center justify-between border-t border-border bg-muted px-3 py-1.5">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span> <span>
{linesLabel} {content.split('\n').length} {linesLabel} {content.split('\n').length}

View File

@@ -49,74 +49,74 @@ export default function CodeEditorHeader({
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save; const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
return ( return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0 gap-2"> <div className="flex min-w-0 flex-shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-1.5">
{/* File info - can shrink */} {/* File info - can shrink */}
<div className="flex items-center gap-2 min-w-0 flex-1 shrink"> <div className="flex min-w-0 flex-1 shrink items-center gap-2">
<div className="min-w-0 shrink"> <div className="min-w-0 shrink">
<div className="flex items-center gap-2 min-w-0"> <div className="flex min-w-0 items-center gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3> <h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
{file.diffInfo && ( {file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap shrink-0"> <span className="shrink-0 whitespace-nowrap rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-600 dark:bg-blue-900 dark:text-blue-300">
{labels.showingChanges} {labels.showingChanges}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p> <p className="truncate text-xs text-gray-500 dark:text-gray-400">{file.path}</p>
</div> </div>
</div> </div>
{/* Buttons - don't shrink, always visible */} {/* Buttons - don't shrink, always visible */}
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex shrink-0 items-center gap-0.5">
{isMarkdownFile && ( {isMarkdownFile && (
<button <button
type="button" type="button"
onClick={onToggleMarkdownPreview} onClick={onToggleMarkdownPreview}
className={`p-1.5 rounded-md flex items-center justify-center transition-colors ${ className={`flex items-center justify-center rounded-md p-1.5 transition-colors ${
markdownPreview markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
}`} }`}
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown} title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
> >
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {markdownPreview ? <Code2 className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={onOpenSettings} onClick={onOpenSettings}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.settings} title={labels.settings}
> >
<SettingsIcon className="w-4 h-4" /> <SettingsIcon className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
onClick={onDownload} onClick={onDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.download} title={labels.download}
> >
<Download className="w-4 h-4" /> <Download className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
onClick={onSave} onClick={onSave}
disabled={saving} disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors ${ className={`flex items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50 ${
saveSuccess saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30' ? 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
}`} }`}
title={saveTitle} title={saveTitle}
> >
{saveSuccess ? ( {saveSuccess ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
) : ( ) : (
<Save className="w-4 h-4" /> <Save className="h-4 w-4" />
)} )}
</button> </button>
@@ -124,20 +124,20 @@ export default function CodeEditorHeader({
<button <button
type="button" type="button"
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen} title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
> >
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />} {isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.close} title={labels.close}
> >
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -15,17 +15,17 @@ export default function CodeEditorLoadingState({
<> <>
<style>{getEditorLoadingStyles(isDarkMode)}</style> <style>{getEditorLoadingStyles(isDarkMode)}</style>
{isSidebar ? ( {isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex h-full w-full items-center justify-center bg-background">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" /> <div className="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span> <span className="text-gray-900 dark:text-white">{loadingText}</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center"> <div className="fixed inset-0 z-[9999] md:flex md:items-center md:justify-center md:bg-black/50">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center"> <div className="code-editor-loading flex h-full w-full items-center justify-center p-8 md:h-auto md:w-auto md:rounded-lg">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" /> <div className="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span> <span className="text-gray-900 dark:text-white">{loadingText}</span>
</div> </div>
</div> </div>

View File

@@ -27,7 +27,7 @@ export default function CodeEditorSurface({
if (markdownPreview && isMarkdownFile) { if (markdownPreview && isMarkdownFile) {
return ( return (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900"> <div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none"> <div className="prose prose-sm mx-auto max-w-4xl max-w-none px-8 py-6 dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg dark:prose-a:text-blue-400">
<MarkdownPreview content={content} /> <MarkdownPreview content={content} />
</div> </div>
</div> </div>

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