mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 18:45:34 +08:00
fix: notification banner would cause refresh of page
This commit is contained in:
22
.github/workflows/discord-release.yml
vendored
Normal file
22
.github/workflows/discord-release.yml
vendored
Normal 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
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit $1
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -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
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
271
README.md
@@ -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
3
commitlint.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
};
|
||||||
102
eslint.config.js
Normal file
102
eslint.config.js
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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
4697
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
103
server/index.js
103
server/index.js
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
|
|
||||||
|
|
||||||
export default TasksSettingsTab;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
8
src/components/auth/constants.ts
Normal file
8
src/components/auth/constants.ts
Normal 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;
|
||||||
222
src/components/auth/context/AuthContext.tsx
Normal file
222
src/components/auth/context/AuthContext.tsx
Normal 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>;
|
||||||
|
}
|
||||||
2
src/components/auth/index.ts
Normal file
2
src/components/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
export { default as ProtectedRoute } from './view/ProtectedRoute';
|
||||||
50
src/components/auth/types.ts
Normal file
50
src/components/auth/types.ts
Normal 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;
|
||||||
|
};
|
||||||
17
src/components/auth/utils.ts
Normal file
17
src/components/auth/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
15
src/components/auth/view/AuthErrorAlert.tsx
Normal file
15
src/components/auth/view/AuthErrorAlert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/auth/view/AuthInputField.tsx
Normal file
37
src/components/auth/view/AuthInputField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/auth/view/AuthLoadingScreen.tsx
Normal file
31
src/components/auth/view/AuthLoadingScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/auth/view/AuthScreenLayout.tsx
Normal file
44
src/components/auth/view/AuthScreenLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/auth/view/LoginForm.tsx
Normal file
90
src/components/auth/view/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/auth/view/ProtectedRoute.tsx
Normal file
41
src/components/auth/view/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
121
src/components/auth/view/SetupForm.tsx
Normal file
121
src/components/auth/view/SetupForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: '' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user